G6: dataset CSV import/export with PT-BR UI labels

Load and save variable datasets as CSV for data-merge pipelines, and
translate the new Variables panel commands in Brazilian Portuguese.

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/target-app-parity.md
This commit is contained in:
Fabio William Conceição
2026-10-16 04:50:07 +07:00
committed by bflatastic
parent a38c824066
commit 5ef090a0c2
7 changed files with 334 additions and 6 deletions
+245 -2
View File
@@ -3,8 +3,9 @@
//! One template document produces many variants: bind a type object to a `text`
//! variable, anything to a `visibility` variable, and a linked image to an `image`
//! variable, put a row of values in each dataset, and `dataset.select` swaps the
//! whole document to that row in one undo step. Agents reach every command through
//! `run_command`, like all engine commands.
//! whole document to that row in one undo step. `dataset.import` / `dataset.export`
//! move those rows as CSV (header: `name` then variable names). Agents reach every
//! command through `run_command`, like all engine commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -12,12 +13,18 @@ use serde_json::{Value, json};
use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables};
use super::edit::selected_roots;
use super::fileio::{absolute_path, read_file, write_file};
use super::textedit::set_plain_text;
use super::*;
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
const MAX_NAME: usize = 255;
/// Caps on a datasets CSV (paths and MCP payloads are untrusted).
const MAX_CSV_BYTES: usize = 1_048_576;
const MAX_CSV_ROWS: usize = 1_000;
const MAX_CSV_COLS: usize = 256;
fn var_name(p: &Value, cmd: &str) -> Result<String> {
named(str_param(p, "name"), "name", cmd)
}
@@ -492,6 +499,224 @@ fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
apply_dataset(s, &var_name(p, "dataset.select")?)
}
/// Bytes of a datasets CSV from `path`, `data`, `dataBase64` or `csv`.
fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64"), str_param(p, "csv")) {
(Some(path), ..) => read_file(&absolute_path(path))?,
(None, Some(text), ..) => text.as_bytes().to_vec(),
(None, None, Some(b64), _) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?,
(None, None, None, Some(csv)) => csv.as_bytes().to_vec(),
_ => return Err(bad(cmd, "give `path`, `data`, `dataBase64` or `csv`")),
};
if bytes.len() > MAX_CSV_BYTES {
return Err(bad(cmd, format!("CSV is larger than {MAX_CSV_BYTES} bytes")));
}
Ok(bytes)
}
/// Split one CSV line into cells (commas; `"` quotes, doubled inside).
fn split_csv_line(line: &str) -> std::result::Result<Vec<String>, String> {
let mut cells = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
let mut in_quotes = false;
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => match chars.peek() {
Some('"') => {
cur.push('"');
chars.next();
}
_ => in_quotes = false,
},
'"' if !in_quotes && cur.is_empty() => in_quotes = true,
',' if !in_quotes => {
cells.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
if in_quotes {
return Err("unclosed quote".into());
}
cells.push(cur);
if cells.len() > MAX_CSV_COLS {
return Err(format!("more than {MAX_CSV_COLS} columns"));
}
Ok(cells)
}
/// Parse a datasets CSV into rows of cells (no header interpretation yet).
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut rows = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
let cells = split_csv_line(line).map_err(|e| bad(cmd, format!("line {}: {e}", i + 1)))?;
rows.push(cells);
if rows.len() > MAX_CSV_ROWS + 1 {
// +1 for the header.
return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows")));
}
}
Ok(rows)
}
fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
}
/// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive).
fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result<bool> {
match s.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(bad(cmd, format!("value for `{var}` must be true or false"))),
}
}
fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result<Option<DataValue>> {
let t = cell.trim();
if t.is_empty() {
return Ok(None);
}
Ok(Some(match kind {
VariableKind::Text => DataValue::Text(t.to_string()),
VariableKind::Image => DataValue::Image(t.to_string()),
VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, cmd)?),
}))
}
fn value_to_cell(v: &DataValue) -> String {
match v {
DataValue::Text(t) | DataValue::Image(t) => t.clone(),
DataValue::Visible(b) => if *b { "true" } else { "false" }.to_string(),
}
}
/// Build the CSV of every dataset (header `name` then variables in definition order).
fn datasets_to_csv(vars: &Variables) -> String {
let mut out = String::from("name");
for v in &vars.variables {
out.push(',');
out.push_str(&csv_escape(&v.name));
}
out.push('\n');
for ds in &vars.datasets {
out.push_str(&csv_escape(&ds.name));
for v in &vars.variables {
out.push(',');
if let Some(val) = ds.values.get(&v.name) {
out.push_str(&csv_escape(&value_to_cell(val)));
}
}
out.push('\n');
}
out
}
/// Import datasets from CSV: first column `name`, remaining headers are variable names.
/// Existing rows of the same name are replaced; new names are appended. One undo step.
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.import";
let bytes = datasets_csv_bytes(p, C)?;
let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?;
let rows = parse_datasets_csv(text, C)?;
let Some(header) = rows.first() else {
return Err(bad(C, "CSV is empty"));
};
if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) {
return Err(bad(C, "first header cell must be `name`"));
}
let (kinds, existing_names) = {
let st = s.doc()?;
let kinds: BTreeMap<String, VariableKind> = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect();
let existing: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
(kinds, existing)
};
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
for (i, h) in header.iter().enumerate().skip(1) {
let name = h.trim();
if name.is_empty() {
return Err(bad(C, format!("empty header in column {}", i + 1)));
}
let kind = kinds.get(name).copied().ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
columns.push((i, name.to_string(), kind));
}
if rows.len() == 1 {
return Err(bad(C, "CSV has a header but no data rows"));
}
let mut parsed: Vec<DataSet> = Vec::new();
for (r_i, row) in rows.iter().enumerate().skip(1) {
let line = r_i + 1;
let raw_name = row.first().map(|c| c.trim()).unwrap_or("");
let name = if raw_name.is_empty() {
// Same numbering as Capture Data Set when a row leaves the name blank.
(1..)
.map(|n| format!("Data Set {n}"))
.find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n))
.unwrap_or_else(|| format!("Data Set {line}"))
} else {
named(Some(raw_name), "name", C)?
};
if parsed.iter().any(|d| d.name == name) {
return Err(bad(C, format!("line {line}: dataset `{name}` appears twice")));
}
let mut values = BTreeMap::new();
for &(col, ref var, kind) in &columns {
let cell = row.get(col).map(String::as_str).unwrap_or("");
match cell_to_value(kind, cell, var, C) {
Ok(Some(v)) => {
values.insert(var.clone(), v);
}
Ok(None) => {}
Err(_) => return Err(bad(C, format!("line {line}: value for `{var}` must match its variable kind"))),
}
}
parsed.push(DataSet { name, values });
}
let names: Vec<String> = parsed.iter().map(|d| d.name.clone()).collect();
let (imported, updated) = s.edit("Import Data Sets", |d, _| {
let mut imported = 0usize;
let mut updated = 0usize;
for row in parsed {
if let Some(existing) = d.variables.datasets.iter_mut().find(|x| x.name == row.name) {
*existing = row;
updated += 1;
} else {
d.variables.datasets.push(row);
imported += 1;
}
}
Ok((imported, updated))
})?;
Ok(json!({"imported": imported, "updated": updated, "names": names}))
}
/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`.
fn dataset_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.export";
let st = s.doc()?;
if st.doc.variables.variables.is_empty() {
return Err(bad(C, "define a variable first"));
}
let csv = datasets_to_csv(&st.doc.variables);
if csv.len() > MAX_CSV_BYTES {
return Err(bad(C, format!("CSV would be larger than {MAX_CSV_BYTES} bytes")));
}
match str_param(p, "path") {
Some(path) => {
let path = absolute_path(path);
write_file(&path, csv.as_bytes())?;
Ok(json!({"path": path, "datasets": st.doc.variables.datasets.len()}))
}
None => Ok(json!({"csv": csv, "datasets": st.doc.variables.datasets.len()})),
}
}
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
let st = s.doc()?;
let n = st.doc.variables.datasets.len();
@@ -657,5 +882,23 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| {
dataset_step(s, "dataset.prev", false)
}),
cmd!(
"dataset.import",
"Import Data Sets…",
["Window", "Variables"],
None,
"{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}",
has_doc,
dataset_import
),
cmd!(
query "dataset.export",
"Export Data Sets…",
["Window", "Variables"],
None,
"{path?} write every dataset as CSV (header: name, then variables); without path → {csv, datasets}",
has_doc,
dataset_export
),
]
}
+52
View File
@@ -877,3 +877,55 @@ fn image_datasets_swap_pixels_and_keep_bounds() {
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
assert!(s.execute("variable.makeImageDynamic", &json!({})).is_err(), "type is not an image");
}
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
#[test]
fn datasets_csv_round_trips_and_rejects_bad_input() {
let dir = Folder::new("vars-csv");
let red_path = dir.file("red.png");
let blue_path = dir.file("blue.png");
write(&red_path, &png(8, 8, RED));
write(&blue_path, &png(8, 8, BLUE));
let mut s = session();
let t = text(&mut s, "Alice");
let r = rect(&mut s);
let img = place(&mut s, &red_path);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Photo", "ids": [img.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Alice", "Show": true, "Photo": red_path}})).unwrap();
s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false, "Photo": blue_path}})).unwrap();
let exported = s.execute("dataset.export", &json!({})).unwrap();
let csv = exported["csv"].as_str().unwrap().to_string();
assert!(csv.starts_with("name,Name,Show,Photo\n"), "{csv}");
assert!(csv.contains("Alice") && csv.contains("Bob") && csv.contains("false"), "{csv}");
// Clear rows, import again, apply B.
s.execute("dataset.delete", &json!({"names": ["A", "B"]})).unwrap();
let imp = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0)));
s.execute("dataset.select", &json!({"name": "B"})).unwrap();
assert_eq!(plain(&s, t), "Bob");
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
assert_eq!(image(&s, img).link.as_ref().map(|l| l.path.as_str()), Some(blue_path.as_str()));
// Re-import updates in place; unknown variable and bad visibility are errors.
let again = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2)));
assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name");
let out = dir.file("sets.csv");
s.execute("dataset.export", &json!({"path": out})).unwrap();
let from_file = s.execute("dataset.import", &json!({"path": out})).unwrap();
assert_eq!(from_file["updated"].as_u64(), Some(2));
// One undo undoes the last import's replacements.
s.execute("edit.undo", &json!({})).unwrap();
}
+2
View File
@@ -873,6 +873,7 @@
Export As… Exportar Como…
Export CSS Exportar CSS
Export CSS… Exportar CSS…
Export Data Sets… Exportar Conjuntos de Dados…
Export DXF Exportar DXF
Export Document Exportar Documento
Export EPS Exportar EPS
@@ -1139,6 +1140,7 @@
Image: Imagem:
Images: Imagens:
Import Keyboard Shortcuts… Importar Atalhos de Teclado…
Import Data Sets… Importar Conjuntos de Dados…
Import PDF Importar PDF
Import PDF Presets Importar Predefinidos de PDF
Import Perspective Grid Presets Importar Predefinidos de Grade de Perspectiva
Can't render this file because it contains an unexpected character in line 6 and column 47.
+25
View File
@@ -293,6 +293,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
delete_active_dataset(app);
}
ui.separator();
if widgets::menu_item(ui, tl!("Import Data Sets…"), true, false) {
import_datasets(app);
}
if widgets::menu_item(ui, tl!("Export Data Sets…"), !vars.is_empty(), false) {
export_datasets(app);
}
ui.separator();
// What the highlighted variable drives, selected — its bindings are left alone.
if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) {
select_bound_object(app);
@@ -302,6 +309,24 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// Variables panel: Import Data Sets… — pick a CSV and run `dataset.import`.
fn import_datasets(app: &mut VectorcraftApp) {
let pick = crate::FilePick { filters: vec![("CSV", &["csv", "txt"])], ..Default::default() };
let Some(path) = crate::picks::open(app, &pick) else { return };
if let Err(e) = app.run("dataset.import", json!({"path": path})) {
app.status(e);
}
}
/// Variables panel: Export Data Sets… — pick a path and run `dataset.export`.
fn export_datasets(app: &mut VectorcraftApp) {
let pick = crate::FilePick { name: "datasets.csv".into(), filters: vec![("CSV", &["csv"])], ..Default::default() };
let Some(path) = crate::picks::save(app, &pick) else { return };
if let Err(e) = app.run("dataset.export", json!({"path": path})) {
app.status(e);
}
}
#[cfg(test)]
mod tests {
use super::*;
+4 -2
View File
@@ -28,7 +28,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 16–27 | no | [target-app-parity.md](target-app-parity.md) |
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) |
| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) |
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 6–12 | no | [target-app-parity.md](target-app-parity.md) |
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) |
| 15 | [G15 Performance budgets and a GPU renderer](#g15-performance-budgets-and-a-gpu-renderer) | 15–25 | budgets only | [hardware-parity.md](hardware-parity.md) |
| 16 | [G16 Localization: the three missing key languages](#g16-localization-the-three-missing-key-languages) | 55–90 | no | [localization-parity.md](localization-parity.md) |
| 17 | [G17 Ecosystem: third-party plug-ins](#g17-ecosystem-third-party-plug-ins) | 20–45 | no | — |
@@ -266,12 +266,13 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G6 Automation and scripting
- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, graph variable
kinds. Actions, Variables (text, visibility and image), dataset CSV import/export and full agent control over
kinds, dataset import/export. Actions, Variables (text, visibility and image) and full agent control over
- **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged.
- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph
variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over
MCP are done.
- **Estimate:** 6–12 h.
- **Estimate:** 4–8 h.
## G15 Performance budgets and a GPU renderer
@@ -319,6 +320,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | G6: dataset CSV import/export (`dataset.import` / `dataset.export`); remaining: scripting surface, Batch, graph variables; estimate 6–12 → 4–8 h |
| 2026-10-14 | minor | G6: image variables (`VariableKind::Image`, `variable.makeImageDynamic`, path values on `dataset.select` keeping bounds); remaining: scripting surface, Batch, graph variables, dataset import/export; estimate 8–14 → 6–12 h |
| 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged |
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
+2 -1
View File
@@ -988,7 +988,8 @@ already walks Image Trace. Short agent entry points:
`brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`.
- Variables: `variable.list`, `variable.define {name, kind: text|visibility|image}`, `variable.bind {variable, ids?}`,
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.unbind`,
`dataset.new` / `dataset.set` / `dataset.select` (image values are file path strings; apply keeps object bounds).
`dataset.new` / `dataset.set` / `dataset.select` (image values are file path strings; apply keeps object bounds),
`dataset.import {path|data|dataBase64|csv}` / `dataset.export {path?}` (CSV: header `name` then variable names).
- Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`),
`imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`.
+4 -1
View File
@@ -89,6 +89,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 65% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables | 4–8 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables, dataset import/export | 6–12 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, ~16 Preferences options stored but unused (#394), panels outside the window, SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions |
@@ -336,6 +337,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 72% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, CSV import/export, panel and Window › Variables with dialogs; graph kinds open) | 4–7 |
| Automation | 3 | 68% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; graph kinds and dataset import/export open) | 5–9 |
| Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 |
@@ -413,7 +415,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Automation:**
- Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a variable whose objects are locked or inside a hidden or locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for.
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
- JSON-lines control channel with real egui pointer and keyboard injection.
- Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709).
- The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.
@@ -653,6 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Automation: dataset CSV import/export; headline Automation ~62% → ~65%, detail ~68% → ~72%, hours 6–12 → 4–8 / 5–9 → 4–7 (estimated) |
| 2026-10-14 | minor | Automation: image variables (`kind: image`, `variable.makeImageDynamic`, path values on datasets that keep object bounds); headline Automation ~60% → ~62%, detail ~65% → ~68%, hours 8–14 → 6–12 / 6–10 → 5–9 (estimated) |
| 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged |
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |