Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a13088840 | ||
|
|
18c9d27c8d | ||
|
|
3b32ea9626 | ||
|
|
42b198e3cb | ||
|
|
35c4a428ff | ||
|
|
1446b4f5dc | ||
|
|
79dd751d3b | ||
|
|
7d6be31711 |
No files matched your search
Generated
+1
@@ -4499,6 +4499,7 @@ dependencies = [
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Graph Design, Column and Marker artwork remain open.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph Design, Column and Marker artwork remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
|
||||
/// for the data window, so a file can't make it allocate without bound.
|
||||
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
|
||||
pub const MAX_GRAPH_SERIES: usize = 256;
|
||||
|
||||
/// The nine graph types, in the Graph tool group's order.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -93,6 +100,10 @@ pub struct GraphSpec {
|
||||
pub series: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub rows: Vec<Vec<f64>>,
|
||||
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
|
||||
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub blanks: Vec<[usize; 2]>,
|
||||
/// Column/bar width as a % of the available category width (90).
|
||||
pub column_width: f64,
|
||||
/// Cluster width as a % (80).
|
||||
@@ -121,6 +132,28 @@ fn edge_to_edge_before_the_option() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
|
||||
/// of [`MAX_GRAPH_SERIES`] cells).
|
||||
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
|
||||
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
|
||||
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
|
||||
self.rows
|
||||
.iter()
|
||||
.take(MAX_GRAPH_CATEGORIES)
|
||||
.enumerate()
|
||||
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
|
||||
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
|
||||
self.blanks =
|
||||
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
|
||||
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GraphSpec {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -130,6 +163,7 @@ impl Default for GraphSpec {
|
||||
series: vec![],
|
||||
categories: vec![],
|
||||
rows: vec![vec![1.0]],
|
||||
blanks: vec![],
|
||||
column_width: 90.0,
|
||||
cluster_width: 80.0,
|
||||
legend: true,
|
||||
|
||||
@@ -65,7 +65,7 @@ pub use appearance::{
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphKind, GraphSpec, SeriesPaint};
|
||||
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
|
||||
+228
-51
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Graph",
|
||||
[],
|
||||
None,
|
||||
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[..]], csv?} → {id}",
|
||||
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Data…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…} replace the graph's data; no data → the current {csv, series, categories, rows}",
|
||||
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…; an empty cell or a null is a blank value, a quoted number a label} replace the graph's data; no data → the current {csv, series, categories, rows}",
|
||||
has_selection,
|
||||
set_data
|
||||
),
|
||||
@@ -50,46 +50,83 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
// ---------- data ----------
|
||||
|
||||
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<f64>>) {
|
||||
let rows: Vec<Vec<String>> = csv
|
||||
/// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value).
|
||||
struct Cell {
|
||||
text: String,
|
||||
quoted: bool,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
fn number(&self) -> Option<f64> {
|
||||
if self.quoted { None } else { self.text.parse::<f64>().ok().filter(|v| v.is_finite()) }
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
|
||||
let rows: Vec<Vec<Cell>> = csv
|
||||
.lines()
|
||||
.map(|l| l.trim_end_matches('\r'))
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let sep = if l.contains('\t') { '\t' } else { ',' };
|
||||
l.split(sep).map(|c| c.trim().trim_matches('"').to_string()).collect()
|
||||
l.split(sep)
|
||||
.map(|c| {
|
||||
let c = c.trim();
|
||||
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
|
||||
Cell { text: c.trim_matches('"').to_string(), quoted }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
|
||||
// A header row has a non-numeric cell after the first one (or an empty first cell).
|
||||
let header = first.first().is_some_and(|c| c.is_empty()) || first.iter().skip(1).any(|c| c.parse::<f64>().is_err());
|
||||
let series = if header { first.iter().skip(1).cloned().collect() } else { vec![] };
|
||||
// A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value.
|
||||
let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty());
|
||||
// A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`).
|
||||
// All-empty series names mean no legend.
|
||||
let empty = |c: &Cell| c.text.is_empty() && !c.quoted;
|
||||
let header = first.first().is_some_and(empty)
|
||||
|| first.iter().skip(1).any(label)
|
||||
|| (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty));
|
||||
let mut series: Vec<String> = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] };
|
||||
if series.iter().all(String::is_empty) {
|
||||
series.clear();
|
||||
}
|
||||
let data = rows.get(usize::from(header)..).unwrap_or_default();
|
||||
// The first column holds the category labels if any row starts with one; then it does for every row, so a
|
||||
// row with an empty label doesn't shift its values one place left.
|
||||
let labelled = data.iter().any(|r| r.first().is_some_and(label));
|
||||
let mut categories = vec![];
|
||||
let mut values = vec![];
|
||||
for r in rows.iter().skip(header as usize) {
|
||||
let labelled = r.first().is_some_and(|c| c.parse::<f64>().is_err());
|
||||
categories.push(if labelled { r[0].clone() } else { String::new() });
|
||||
values.push(r.iter().skip(labelled as usize).map(|c| c.parse::<f64>().unwrap_or(0.0)).collect());
|
||||
}
|
||||
if categories.iter().all(String::is_empty) {
|
||||
categories.clear();
|
||||
for r in data {
|
||||
if labelled {
|
||||
categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default());
|
||||
}
|
||||
values.push(r.iter().skip(labelled as usize).map(Cell::number).collect());
|
||||
}
|
||||
(series, categories, values)
|
||||
}
|
||||
|
||||
fn to_csv(g: &GraphSpec) -> String {
|
||||
let mut out = String::new();
|
||||
if !g.series.is_empty() {
|
||||
out.push(',');
|
||||
out.push_str(&g.series.join(","));
|
||||
out.push('\n');
|
||||
}
|
||||
for (i, r) in g.rows.iter().enumerate() {
|
||||
if let Some(c) = g.categories.get(i) {
|
||||
out.push_str(c);
|
||||
// Always a header row (empty first cell), so a first data row starting with a blank isn't read as one.
|
||||
let cells = g.cells();
|
||||
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len());
|
||||
let mut out = String::from(",");
|
||||
out.push_str(&(0..width).map(|s| g.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
let labelled = !g.categories.is_empty();
|
||||
for (i, r) in cells.iter().enumerate() {
|
||||
if labelled {
|
||||
// An empty label, one that reads as a number or one with spaces around it goes in quotes, so it reads back
|
||||
// as the same label.
|
||||
let c = g.categories.get(i).map(String::as_str).unwrap_or_default();
|
||||
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
|
||||
out.push_str(&format!("\"{c}\""));
|
||||
} else {
|
||||
out.push_str(c);
|
||||
}
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&r.iter().map(|v| fmt_value(*v)).collect::<Vec<_>>().join(","));
|
||||
out.push_str(&r.iter().map(|v| v.map(fmt_value).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
@@ -101,7 +138,7 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
|
||||
let (s, c, r) = parse_csv(csv);
|
||||
g.series = s;
|
||||
g.categories = c;
|
||||
g.rows = r;
|
||||
g.set_cells(r);
|
||||
changed = true;
|
||||
}
|
||||
let strings =
|
||||
@@ -115,10 +152,24 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
|
||||
changed = true;
|
||||
}
|
||||
if let Some(rows) = p.get("rows").and_then(Value::as_array) {
|
||||
g.rows = rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().unwrap_or(0.0)).collect()).unwrap_or_default()).collect();
|
||||
// `null` (or anything that isn't a finite number) is a blank cell.
|
||||
g.set_cells(
|
||||
rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
g.rows.retain(|r| !r.is_empty());
|
||||
// Drop rows with neither a value nor a label; a label-only row stays, its values blank.
|
||||
let mut cells = g.cells();
|
||||
let keep: Vec<bool> = (0..cells.len())
|
||||
.map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty()))
|
||||
.collect();
|
||||
if keep.contains(&false) || cells.len() < g.rows.len() {
|
||||
let mut k = keep.iter();
|
||||
cells.retain(|_| k.next().copied().unwrap_or(false));
|
||||
let mut k = keep.iter();
|
||||
g.categories.retain(|_| k.next().copied().unwrap_or(true));
|
||||
g.set_cells(cells);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -234,6 +285,11 @@ impl Gen<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stretches of two or more points between blank cells, which a line connects.
|
||||
fn runs(pts: &[Option<Point>]) -> Vec<Vec<Point>> {
|
||||
pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::<Vec<_>>()).filter(|r| r.len() > 1).collect()
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point], closed: bool) -> PathData {
|
||||
let mut bp = BezPath::new();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
@@ -262,7 +318,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let ncat = g.rows.len().max(1);
|
||||
let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default();
|
||||
let mut series: Vec<Vec<Arc<Node>>> = vec![vec![]; nser];
|
||||
let val = |c: usize, s: usize| g.rows.get(c).and_then(|r| r.get(s)).copied().unwrap_or(0.0);
|
||||
// A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points
|
||||
// break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them.
|
||||
let cells = g.cells();
|
||||
let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten();
|
||||
let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0);
|
||||
let stacked = matches!(g.kind, GraphKind::StackedColumn | GraphKind::StackedBar | GraphKind::Area);
|
||||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||||
|
||||
@@ -347,15 +407,17 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
lo = lo.min(neg);
|
||||
} else if scatter {
|
||||
for s in (0..nser).step_by(2) {
|
||||
hi = hi.max(val(c, s));
|
||||
lo = lo.min(val(c, s));
|
||||
xlo = xlo.min(val(c, s + 1));
|
||||
xhi = xhi.max(val(c, s + 1));
|
||||
if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) {
|
||||
hi = hi.max(y);
|
||||
lo = lo.min(y);
|
||||
xlo = xlo.min(x);
|
||||
xhi = xhi.max(x);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for s in 0..nser {
|
||||
hi = hi.max(val(c, s));
|
||||
lo = lo.min(val(c, s));
|
||||
for v in (0..nser).filter_map(|s| cell(c, s)) {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,14 +477,20 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
x += xs;
|
||||
}
|
||||
for (si, s) in (0..nser).step_by(2).enumerate() {
|
||||
let pts: Vec<Point> =
|
||||
(0..ncat).map(|c| Point::new(r.x0 + (val(c, s + 1) - xl) / (xh - xl) * r.width(), vpos(val(c, s)))).collect();
|
||||
if g.connect_points && pts.len() > 1 {
|
||||
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
|
||||
series[si].push(b.path(polyline(&pts, false), fill, stroke, w));
|
||||
let pts: Vec<Option<Point>> = (0..ncat)
|
||||
.map(|c| match (cell(c, s), cell(c, s + 1)) {
|
||||
(Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(y))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if g.connect_points {
|
||||
for run in runs(&pts) {
|
||||
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
|
||||
series[si].push(b.path(polyline(&run, false), fill, stroke, w));
|
||||
}
|
||||
}
|
||||
if g.mark_points {
|
||||
for p in &pts {
|
||||
for p in pts.iter().flatten() {
|
||||
let (fill, stroke, w) = mark(si, true, Paint::None, 0.0);
|
||||
series[si].push(b.path(marker(*p, 5.0), fill, stroke, w));
|
||||
}
|
||||
@@ -439,8 +507,9 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
for c in 0..ncat {
|
||||
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * s as f64 + (slot - bar) / 2.0;
|
||||
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(val(c, s)));
|
||||
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(v));
|
||||
let rect = if horizontal {
|
||||
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
|
||||
} else {
|
||||
@@ -458,7 +527,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let a = cat_start(c) + (cat_w - bar) / 2.0;
|
||||
let (mut pos, mut neg) = (0.0, 0.0);
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let v = val(c, s);
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let base = if v >= 0.0 { &mut pos } else { &mut neg };
|
||||
let (v0, v1) = (vpos(*base), vpos(*base + v));
|
||||
*base += v;
|
||||
@@ -474,13 +543,15 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
GraphKind::Line => {
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(val(c, s)))).collect();
|
||||
if g.connect_points && pts.len() > 1 {
|
||||
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
|
||||
items.push(b.path(polyline(&pts, false), fill, stroke, w));
|
||||
let pts: Vec<Option<Point>> = (0..ncat).map(|c| cell(c, s).map(|v| Point::new(cat_mid(c), vpos(v)))).collect();
|
||||
if g.connect_points {
|
||||
for run in runs(&pts) {
|
||||
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
|
||||
items.push(b.path(polyline(&run, false), fill, stroke, w));
|
||||
}
|
||||
}
|
||||
if g.mark_points {
|
||||
for p in &pts {
|
||||
for p in pts.iter().flatten() {
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.path(marker(*p, 5.0), fill, stroke, w));
|
||||
}
|
||||
@@ -697,7 +768,8 @@ fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "graph.setData")?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
if !apply_data(&mut spec, p) {
|
||||
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.rows }));
|
||||
// Blank cells come back as null, so the rows can be edited and sent back as they are.
|
||||
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() }));
|
||||
}
|
||||
s.edit("Graph Data", |d, _| regenerate(d, id, spec))?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
@@ -1133,10 +1205,115 @@ mod tests {
|
||||
let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n");
|
||||
assert_eq!(s, vec!["2024", "2025"]);
|
||||
assert_eq!(c, vec!["Q1", "Q2"]);
|
||||
assert_eq!(r, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
|
||||
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
|
||||
let (s, c, r) = super::parse_csv("1,2\n3,4");
|
||||
assert!(s.is_empty() && c.is_empty());
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
|
||||
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
|
||||
let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4");
|
||||
assert_eq!(s, vec!["a", "b"]);
|
||||
assert_eq!(c, vec!["2023", "2024", "2025"]);
|
||||
assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]);
|
||||
// Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same.
|
||||
let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() };
|
||||
g.set_cells(r);
|
||||
assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]);
|
||||
let csv = super::to_csv(&g);
|
||||
assert!(csv.contains("\"2024\",,"), "{csv}");
|
||||
let (s2, c2, r2) = super::parse_csv(&csv);
|
||||
assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_with_an_empty_label_keeps_its_values_in_place() {
|
||||
// The label column is the first column of every row once any row has a label.
|
||||
let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4");
|
||||
assert_eq!(c, vec!["Q1", ""]);
|
||||
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
|
||||
// And a data window round trip keeps it (the empty label is written as "").
|
||||
let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() };
|
||||
g.set_cells(r);
|
||||
assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells());
|
||||
// Without series or labels, a first row starting with a blank is still data after a round trip.
|
||||
let mut g = GraphSpec::default();
|
||||
g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]);
|
||||
let (s, c, r) = super::parse_csv(&super::to_csv(&g));
|
||||
assert!(s.is_empty() && c.is_empty());
|
||||
assert_eq!(r, g.cells());
|
||||
// A label with spaces around a number stays a label, spaces and all.
|
||||
let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() };
|
||||
g.set_cells(vec![vec![Some(1.0)]]);
|
||||
assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let r =
|
||||
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]]));
|
||||
// A label followed by empty cells is a header with no series names, as before blanks existed.
|
||||
let (s, c, r) = super::parse_csv("Year,,\n2023,1,2");
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(c, Vec::<String>::new());
|
||||
assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_ragged_grid_is_capped() {
|
||||
let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() };
|
||||
assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_only_row_stays_with_blank_values() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let r =
|
||||
s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
|
||||
assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]);
|
||||
assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_cells_leave_out_their_columns_and_break_lines() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]);
|
||||
let r = s
|
||||
.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows}))
|
||||
.unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len();
|
||||
// Three columns each (one blank), plus the legend swatch.
|
||||
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
|
||||
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
|
||||
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
|
||||
// Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three
|
||||
// markers and the legend swatch.
|
||||
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
|
||||
assert_eq!(count(&s, 0), 1 + 3 + 1);
|
||||
// Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch.
|
||||
assert_eq!(count(&s, 1), 1 + 3 + 1);
|
||||
// A data window round trip keeps the blanks.
|
||||
let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
|
||||
assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}");
|
||||
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
|
||||
assert_eq!(spec.cells()[1], vec![None, Some(2.0)]);
|
||||
// In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads
|
||||
// numbers (as zeros); a graph without blanks writes no `blanks` at all.
|
||||
let v = serde_json::to_value(&spec).unwrap();
|
||||
assert_eq!(v["rows"][1], json!([0.0, 2.0]));
|
||||
assert_eq!(v["blanks"], json!([[1, 0], [2, 1]]));
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
# Windows metafile previews, opened when a file's PostScript can't be read.
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
# The TIFF writer (previews) and PackBits, shared with raster export.
|
||||
vectorcraft-render = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
||||
@@ -37,6 +37,7 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj::{self, Obj, V};
|
||||
use super::paint::{self, GradientDef, Instance, Named};
|
||||
use crate::ps;
|
||||
|
||||
/// Deepest nesting of layers, groups and dictionaries.
|
||||
const MAX_DEPTH: usize = 256;
|
||||
@@ -248,6 +249,8 @@ struct Reader<'a> {
|
||||
hidden_ids: HashSet<NodeId>,
|
||||
/// CMYK samples converted, by value.
|
||||
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
|
||||
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
|
||||
foreign_data: Option<Vec<u8>>,
|
||||
/// The page ended: what follows isn't art.
|
||||
done: bool,
|
||||
/// The dictionary closed last was opened on a `%_` line.
|
||||
@@ -316,8 +319,11 @@ const SKIPPED: &[(&str, &str)] = &[
|
||||
("Alternate_Content", "End_Versioned_Content"),
|
||||
];
|
||||
|
||||
/// The note for a `/ForeignObject`, art the editor shows but doesn't edit.
|
||||
pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet";
|
||||
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
|
||||
/// shows but doesn't edit) whose PDF can't be read.
|
||||
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
|
||||
/// The name of the group a `/ForeignObject`'s art comes in as.
|
||||
pub const NON_NATIVE_ART: &str = "Non-native art";
|
||||
|
||||
/// A section comment's marker without its `AI<version>_` prefix and value (`AI14_BeginSymbol` →
|
||||
/// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`.
|
||||
@@ -350,6 +356,7 @@ impl<'a> Reader<'a> {
|
||||
pending_stroke: None,
|
||||
spots: Vec::new(),
|
||||
raster: None,
|
||||
foreign_data: None,
|
||||
after_object: false,
|
||||
nodes: 0,
|
||||
unsupported: BTreeSet::new(),
|
||||
@@ -659,8 +666,10 @@ impl<'a> Reader<'a> {
|
||||
// of it may start with `%_` and read as hidden tokens: a `(` there would open
|
||||
// a string that swallows the rest of the layer.
|
||||
let key = o.entries.last().and_then(|(k, _)| k.as_deref());
|
||||
if (o.ty == "Binary" && key == Some("ASCII85Decode")) || (o.ty == "ForeignObject" && key == Some("Data")) {
|
||||
if o.ty == "Binary" && key == Some("ASCII85Decode") {
|
||||
self.lex.skip_past(b"~>");
|
||||
} else if o.ty == "ForeignObject" && key == Some("Data") {
|
||||
self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
@@ -839,9 +848,7 @@ impl<'a> Reader<'a> {
|
||||
"Document" => self.document(&o),
|
||||
"AI11Text" => self.text_slot(&o)?,
|
||||
"SymbolInstance" => self.unreadable("symbols"),
|
||||
// Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in
|
||||
// the dictionary, which nothing draws from yet, so the file opens without it.
|
||||
"ForeignObject" => self.warn(NON_NATIVE_ART),
|
||||
"ForeignObject" => self.foreign_art(&o)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -858,6 +865,94 @@ impl<'a> Reader<'a> {
|
||||
self.add(n, hidden)
|
||||
}
|
||||
|
||||
/// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but
|
||||
/// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler),
|
||||
/// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform`
|
||||
/// maps that space onto the art (both read off files: the box lands on the clipping paths
|
||||
/// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one
|
||||
/// group. Without data, or with a PDF that can't be read, nothing is drawn.
|
||||
fn foreign_art(&mut self, o: &Obj) -> Result<(), String> {
|
||||
let Some(text) = self.foreign_data.take() else { return Ok(()) };
|
||||
// One `%` starts each line; the rest, `_` included, is data.
|
||||
let mut encoded = String::with_capacity(text.len());
|
||||
for line in text.split(|b| matches!(b, b'\r' | b'\n')) {
|
||||
encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line)));
|
||||
}
|
||||
let Some(pdf) = ps::ascii85_decode(&encoded) else {
|
||||
self.warn(NON_NATIVE_ART_UNREAD);
|
||||
return Ok(());
|
||||
};
|
||||
let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums));
|
||||
let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else {
|
||||
self.warn(NON_NATIVE_ART_UNREAD);
|
||||
return Ok(());
|
||||
};
|
||||
let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]);
|
||||
let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1));
|
||||
if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 {
|
||||
self.warn(NON_NATIVE_ART_UNREAD);
|
||||
return Ok(());
|
||||
}
|
||||
let target = self.ensure_space().transform_rect_bbox(art_box);
|
||||
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
|
||||
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})"));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mut art: Vec<Node> = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect();
|
||||
let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) };
|
||||
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
// The art's own box onto the object's box (its measure of the same art), upside down:
|
||||
// the object's space has y down, and the PDF's content is kept in its own y-up
|
||||
// coordinates, so the PDF's bottom is the top of the box.
|
||||
let fit = Affine::translate((target.x0, target.y1))
|
||||
* Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height())
|
||||
* Affine::translate((-bbox.x0, -bbox.y0));
|
||||
for n in &mut art {
|
||||
n.transform(fit, true);
|
||||
// The PDF's swatches stay with it: its colours keep their values, unlinked.
|
||||
self.reid(n);
|
||||
}
|
||||
for (key, blob) in read.document.images {
|
||||
self.doc.images.entry(key).or_insert(blob);
|
||||
}
|
||||
let gs = self.gs.clone();
|
||||
let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs);
|
||||
group.name = Some(NON_NATIVE_ART.into());
|
||||
self.add(group, self.obj_hidden)
|
||||
}
|
||||
|
||||
/// Give `n` and its descendants ids of this document, and unlink their swatches.
|
||||
fn reid(&mut self, n: &mut Node) {
|
||||
n.id = self.doc.alloc_id();
|
||||
for it in &mut n.appearance.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = p {
|
||||
g.swatch = None;
|
||||
}
|
||||
p.map_links(&mut |_, link, _| {
|
||||
*link = None;
|
||||
true
|
||||
});
|
||||
}
|
||||
if let Some(m) = &mut n.mask {
|
||||
self.reid(Arc::make_mut(&mut m.art));
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children {
|
||||
self.reid(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Note something the data has that isn't read: an error on a layer that shows, left out of a
|
||||
/// layer that doesn't.
|
||||
fn unreadable(&mut self, what: &str) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use kurbo::{Point, Rect};
|
||||
use vectorcraft_color::gradient::GradientKind;
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -332,13 +332,102 @@ fn sections_it_skips() {
|
||||
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
|
||||
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
|
||||
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
|
||||
/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note.
|
||||
/// 28 wouldn't open: "it ends inside a layer"). Data that isn't a PDF is left out with a note.
|
||||
#[test]
|
||||
fn non_native_art_is_skipped_with_its_ascii85_data() {
|
||||
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
|
||||
fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() {
|
||||
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
|
||||
let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")));
|
||||
assert_eq!(art(&s.doc).len(), 1);
|
||||
assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings);
|
||||
assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// A PDF with one page of `size` points holding `content`.
|
||||
fn tiny_pdf(size: f64, content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size} {size}] /Contents 4 0 R >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![];
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// `bytes` as ASCII85 in comment lines of 60 characters, ending with `~>`.
|
||||
fn ascii85_comment_lines(bytes: &[u8]) -> String {
|
||||
let mut text = String::new();
|
||||
for chunk in bytes.chunks(4) {
|
||||
let mut word = [0u8; 4];
|
||||
word[..chunk.len()].copy_from_slice(chunk);
|
||||
let mut v = u32::from_be_bytes(word);
|
||||
let mut digits = [0u8; 5];
|
||||
for d in digits.iter_mut().rev() {
|
||||
*d = (v % 85) as u8 + b'!';
|
||||
v /= 85;
|
||||
}
|
||||
text.extend(digits.iter().take(chunk.len() + 1).map(|d| char::from(*d)));
|
||||
}
|
||||
let mut lines = String::new();
|
||||
for line in text.as_bytes().chunks(60) {
|
||||
lines.push('%');
|
||||
lines.push_str(std::str::from_utf8(line).unwrap());
|
||||
lines.push('\n');
|
||||
}
|
||||
lines.push_str("%~>\n");
|
||||
lines
|
||||
}
|
||||
|
||||
/// The PDF of non-native art is drawn by the PDF importer, its art fitted to the object's box:
|
||||
/// `/Bounds` in a space measured from `/Origin` with y down, then `/RTransform`. Two squares at
|
||||
/// 1..5 × 2..8 of a 10-point page, blue below red: bounds (1, 2)–(5, 8) → from the origin
|
||||
/// (10, 20), y down, (11, 18)–(15, 12) → twice that plus (100, 50): x 122..130, y 74..86 in art
|
||||
/// space, which is y 14..26 in the document (crop marks 100 high). The space has y down, so the
|
||||
/// PDF's bottom (blue) is the top of the box.
|
||||
#[test]
|
||||
fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() {
|
||||
let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f");
|
||||
let foreign = format!(
|
||||
"q\n/ForeignObject :\n1 /Version ,\n2 0 0 2 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n",
|
||||
ascii85_comment_lines(&pdf),
|
||||
rect_path(120.0, 70.0, 130.0, 90.0)
|
||||
);
|
||||
let s = read_ok(&layer(&foreign));
|
||||
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
|
||||
let objects = art(&s.doc);
|
||||
assert_eq!(objects.len(), 1, "{objects:?}");
|
||||
// A clipping group: the clip path first, then the non-native art's group.
|
||||
let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) };
|
||||
assert_eq!(children.len(), 2);
|
||||
let group = &children[1];
|
||||
assert_eq!(group.name.as_deref(), Some(read::NON_NATIVE_ART));
|
||||
let bounds = group.geometric_bounds().unwrap();
|
||||
for (got, want) in [(bounds.x0, 122.0), (bounds.y0, 14.0), (bounds.x1, 130.0), (bounds.y1, 26.0)] {
|
||||
assert!((got - want).abs() < 1e-6, "{bounds:?}");
|
||||
}
|
||||
let paths = group.children().unwrap();
|
||||
assert_eq!(paths.len(), 2);
|
||||
let fill =
|
||||
|n: &Node| n.appearance.items.iter().find_map(|it| if let AppearanceItem::Fill(f) = it { Some(f.paint.clone()) } else { None }).unwrap();
|
||||
let (blue, red) = (fill(&paths[0]), fill(&paths[1]));
|
||||
assert!(matches!(&blue, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [0, 0, 255, 255]), "{blue:?}");
|
||||
assert!(matches!(&red, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [255, 0, 0, 255]), "{red:?}");
|
||||
let (b, r) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap());
|
||||
assert!((b.y0 - 14.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "blue {b:?}");
|
||||
assert!((r.y0 - 20.0).abs() < 1e-6 && (r.y1 - 26.0).abs() < 1e-6, "red {r:?}");
|
||||
// Its ids are this document's.
|
||||
assert_ne!(group.id, paths[0].id);
|
||||
assert_ne!(paths[0].id, paths[1].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -419,7 +419,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"create_graph",
|
||||
"Create graph",
|
||||
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
|
||||
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. An empty CSV cell or a null in `rows` is a blank value (no column; lines break around it); a number in straight quotes is a label. Edit later with run_command graph.setData / graph.setType.",
|
||||
obj(
|
||||
json!({
|
||||
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},
|
||||
@@ -430,7 +430,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"csv": {"type": "string"},
|
||||
"series": {"type": "array", "items": {"type": "string"}},
|
||||
"categories": {"type": "array", "items": {"type": "string"}},
|
||||
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
|
||||
"rows": {"type": "array", "items": {"type": "array", "items": {"type": ["number", "null"]}}},
|
||||
}),
|
||||
&["x", "y", "width", "height"],
|
||||
),
|
||||
|
||||
@@ -367,13 +367,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
|
||||
}
|
||||
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
|
||||
painter.text(
|
||||
r.left_top() - vec2(0.0, 4.0),
|
||||
egui::Align2::LEFT_BOTTOM,
|
||||
format!("{:02} - {}", i + 1, ab.name),
|
||||
egui::FontId::proportional(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
|
||||
}
|
||||
}
|
||||
if app.ui.view.guides {
|
||||
@@ -1749,6 +1743,22 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
|
||||
/// out rather than spilling over its neighbours.
|
||||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||||
|
||||
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
|
||||
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||||
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
|
||||
if r.width() < MIN_LABELLED_ARTBOARD {
|
||||
return;
|
||||
}
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
let galley = painter.layout_job(job);
|
||||
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
|
||||
}
|
||||
|
||||
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
|
||||
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
|
||||
fn names_an_artboard(text: &str) -> bool {
|
||||
@@ -2219,6 +2229,31 @@ mod tests {
|
||||
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
|
||||
}
|
||||
|
||||
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
|
||||
/// longer, and an artboard too narrow shows none.
|
||||
#[test]
|
||||
fn artboard_labels_fit_their_artboard() {
|
||||
let ctx = egui::Context::default();
|
||||
let drawn = |width: f32| {
|
||||
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.into_iter()
|
||||
.filter_map(|c| match c.shape {
|
||||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let full = drawn(400.0);
|
||||
assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name"));
|
||||
// Laid out no wider than the artboard (the galley keeps the whole text; it draws "…").
|
||||
let (_, w) = drawn(30.0).first().cloned().unwrap();
|
||||
assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}");
|
||||
assert!(drawn(10.0).is_empty());
|
||||
}
|
||||
|
||||
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
+5
-4
@@ -282,7 +282,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
|
||||
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
|
||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
||||
| `undo` / `redo` | `{}` | |
|
||||
|
||||
@@ -521,9 +521,10 @@ CMYK too.
|
||||
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
|
||||
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
|
||||
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type,
|
||||
area type and type on a path), and what can't be is left out with a warning; so is non-native art (the content of a
|
||||
placed PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data); without editing data
|
||||
it says it can't be opened.
|
||||
area type and type on a path), and what can't be is left out with a warning. Non-native art (the content of a placed
|
||||
PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data) is drawn by the PDF importer,
|
||||
as a group named "Non-native art" fitted to the object's box; one whose PDF can't be read is left out with a warning.
|
||||
Without editing data it says it can't be opened.
|
||||
|
||||
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
|
||||
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
|
||||
|
||||
@@ -75,6 +75,8 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
||||
&["text", "effects"],
|
||||
&["render", "eps"],
|
||||
&["metafile", "eps"],
|
||||
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
|
||||
&["pdf", "eps"],
|
||||
&["plugins", "effects"],
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user