Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 2cfee9b99b Docs: type parity has one status line again
Two branches' status lines both landed in a merge; they are one line
again, naming both changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:30:12 +07:00
BFlat 90bd91986a Merge pull request #1186 from storytold/fix/thai-caret-clusters
Type: the caret steps over whole clusters (Thai marks, accents, emoji)
2026-10-11 19:29:37 +07:00
bflatasticandClaude Opus 5.5 3fb51869ac Type: the caret steps over whole clusters (Thai marks, accents, emoji)
The Type tool's Left and Right arrows and Delete moved one code point at
a time, so the caret could stop between a Thai consonant and its vowel
or tone marks (or inside an accented letter or emoji sequence), where
typing or deleting splits the cluster. They now step over extended
grapheme clusters. Backspace still takes back one character at a time,
as Thai typists expect for a mistyped tone mark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:29:16 +07:00
BFlat 436d7ecdd3 Merge pull request #1185 from storytold/fix/thai-font-fallback
Type: Thai falls back to a font made for Thai
2026-10-11 19:28:58 +07:00
bflatasticandClaude Opus 5.5 fba3702103 Type: Thai falls back to a font made for Thai
Thai typed in a font without Thai glyphs (Source Sans 3, a Latin font)
took the first loaded face that had them, or the first installed file
that did, in path order: on one Windows machine a display face (Kanit)
rather than Leelawadee UI. Characters of Thai, Lao, Khmer and Myanmar
now try fonts made for their script first, installed ones loaded on
demand (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai,
the TLWG fonts for Thai), then the fallback as before. Latin beside
them keeps the font chosen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:42:31 +07:00
BFlat 7c7a7dcdc9 Merge pull request #1181 from storytold/fix/1163-file-list
#1163: file.list lists a folder for agents, held to the read root
2026-10-11 18:35:45 +07:00
bflatasticandClaude Opus 5.5 339b01f48b #1163: file.list lists a folder for agents, held to the read root
An agent driving VectorCraft over the confined MCP server had no way to
see which files it was given, so it guessed names. `file.list {path}`
lists a folder's files (with their sizes) and folders by name, held to
the automation read root like every read; links are listed as links and
not followed. At most 5000 entries, with `truncated` saying when there
were more.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:35:23 +07:00
BFlat 6d07135456 Merge pull request #1179 from storytold/fix/1163-atomic-batch
#1163: command_batch can be one undo step
2026-10-11 18:22:58 +07:00
bflatasticandClaude Opus 5.5 ed80181d70 #1163: command_batch can be one undo step
An agent's build over MCP left one undo step per batch step (about 280
for one job). `command_batch` takes `atomic: true` (and an optional
`label` naming the step) to make the whole batch one undo step. It runs
the batch in an undo group, opened and closed by the new commands
`edit.undoGroup.begin {label?}` and `edit.undoGroup.end {cancel?}`, so
every backend (headless or the connected app) supports it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:22:45 +07:00
BFlat 24eaf5e037 Merge pull request #1147 from KnottyDyes/squad/raster-effect-resolution-warning
PDF export: warn when raster effects render below the requested resolution
2026-10-11 18:18:02 +07:00
bflatasticandClaude Opus 5.5 4603ebec7a Review: report module beside rasterfx, ids checked without a tree walk, docs trimmed (#1147)
The report module moves to cmd/rasterfx/report.rs (the crate's foo.rs +
foo/ layout instead of #[path]). An object is the document's when its id
is below the document's next id, so building the report no longer walks
the whole document (and its symbols and patterns) on every PDF, SVG,
print and clipboard export. The parity docs say what changed in the
status line and one evidence bullet instead of a separate paragraph.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:17:25 +07:00
KnottyDyes 8d881cc0bc PDF export: warn when raster effects render below the requested resolution
The 64 million pixel cap can silently lower raster-effect resolution. Report each original object's id and name with requested and achieved ppi through the existing export warnings, retaining the lowest resolution across its images.

Keep the cap and rendering unchanged, cover capped and uncapped exports, and record the evidence without changing parity scores.
2026-10-11 18:17:25 +07:00
BFlat 2ecc83e700 Merge pull request #1178 from storytold/review/1157-followup
Review: house header for the root-argument integration test (#1157)
2026-10-11 18:17:11 +07:00
bflatasticandClaude Opus 5.5 731c30ff6d Review: house header for the root-argument integration test (#1157)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:16:43 +07:00
BFlat 71b7ae3d56 Merge pull request #1157 from piero-83/ll-root-preflight
Reject malformed desktop automation root arguments before startup
2026-10-11 18:15:54 +07:00
BFlat 6eaaccb717 Merge pull request #1148 from KnottyDyes/squad/new-art-paint-docs
MCP: drawing tools describe inherited paint and export text options
2026-10-11 18:15:10 +07:00
bflatasticandClaude Opus 5.5 805d30e284 Review: DXF outlineText note and new-art wording in the MCP docs (#1148)
DXF already outlines type unless preserve: editability, so say so next to
the outlineText flag; reword the new-art sentence and point to paint.default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:14:25 +07:00
KnottyDyes 4505ccb20d MCP: drawing tools describe inherited paint and export text options
Omitted paint fields keep the current appearance, so agents can get a black outline when they only set a fill. Document the initial white fill and 1 pt black stroke and recommend explicit paint for outline-free art.

Clarify SVG/SVGZ outlineText versus PDF options.advanced.outlineText and PDF's live-text default, noting DXF also accepts the top-level flag. Descriptions and documentation only; drawing and export behavior are unchanged.
2026-10-11 18:14:23 +07:00
BFlat 433c8d5aa7 Merge pull request #1177 from storytold/fix/1163-preview-artboard
#1163: MCP render_preview takes an artboard index
2026-10-11 18:13:36 +07:00
bflatasticandClaude Opus 5.5 c3719b2e79 #1163: MCP render_preview takes an artboard index
render_preview only rendered the first artboard, so an agent couldn't
look at pages 2 and on of a multi-artboard document without switching
to screenshot. It now takes an optional 0-based `artboard` (as
screenshot does) and refuses an index the document doesn't have.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:13:26 +07:00
BFlat 9aed6191d4 Merge pull request #1176 from storytold/fix/1163-release-subpaths
#1163: Release splits a path of several subpaths into one path each
2026-10-11 17:58:25 +07:00
piero-83 87a9fb06cd docs: explain strict automation root arguments 2026-10-11 11:18:49 +02:00
piero-83 dea33fe5b4 test(cli): preserve confinement after rejected paths 2026-10-11 11:18:43 +02:00
piero-83 d9c48bddef test(app): reject root errors before initialization 2026-10-11 11:18:31 +02:00
piero-83 3d4caa917a fix(app): reject malformed automation roots before startup 2026-10-11 11:17:58 +02:00
36 changed files with 804 additions and 78 deletions

No files matched your search

Generated
+1
View File
@@ -4813,6 +4813,7 @@ dependencies = [
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"unicode-segmentation",
"vectorcraft-doc",
"vectorcraft-geom",
"windows-registry",
+1 -1
View File
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+39 -8
View File
@@ -94,6 +94,15 @@ fn mcp_automation_roots() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
std::fs::write(base.join("in/a.svg"), svg).unwrap();
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
std::fs::write(base.join("outside/victim.png"), b"keep this file").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(base.join("outside/b.svg"), base.join("in/escape.svg")).unwrap();
symlink(base.join("outside"), base.join("out/escape")).unwrap();
symlink(base.join("outside/victim.png"), base.join("out/victim.png")).unwrap();
symlink(base.join("outside/missing.png"), base.join("out/dangling.png")).unwrap();
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let fails = |args: &[&str], why: &str| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
@@ -114,15 +123,32 @@ fn mcp_automation_roots() {
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut calls = vec![
call(1, "open_file", json!({"path": file("in", "a.svg")})),
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
call(3, "export", json!({"path": file("out", "a.png")})),
call(4, "export", json!({"path": file("in", "a.png")})),
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
call(6, "open_file", json!({"path": file("in", "../outside/b.svg")})),
call(7, "export", json!({"path": file("out", "../outside/traversal.png")})),
];
let mut expected_errors = vec![false, true, false, true, true, true, true];
#[cfg(unix)]
{
calls.extend([
call(8, "open_file", json!({"path": file("in", "escape.svg")})),
call(9, "export", json!({"path": file("out", "escape/escaped.png")})),
call(10, "export", json!({"path": file("out", "victim.png")})),
call(11, "export", json!({"path": file("out", "dangling.png")})),
]);
expected_errors.extend([true; 4]);
}
// An access error is per request: the server continues and can still save inside its root.
calls.push(call(12, "save_file", json!({"path": file("out", "allowed.vectorcraft")})));
expected_errors.push(false);
{
let mut stdin = child.stdin.take().unwrap();
for m in [
call(1, "open_file", json!({"path": file("in", "a.svg")})),
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
call(3, "export", json!({"path": file("out", "a.png")})),
call(4, "export", json!({"path": file("in", "a.png")})),
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
] {
for m in calls {
writeln!(stdin, "{m}").unwrap();
}
}
@@ -130,9 +156,14 @@ fn mcp_automation_roots() {
assert!(out.status.success());
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, true, false, true, true], "{replies:?}");
assert_eq!(errors, expected_errors, "{replies:?}");
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
assert!(base.join("out/allowed.vectorcraft").is_file());
for denied in ["traversal.png", "escaped.png", "missing.png"] {
assert!(!base.join("outside").join(denied).exists(), "{denied}");
}
assert_eq!(std::fs::read(base.join("outside/victim.png")).unwrap(), b"keep this file");
}
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
+90
View File
@@ -0,0 +1,90 @@
//! Parse automation roots before starting any desktop services. A malformed flag must not
//! silently remove a root and leave the control channel unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. CLI values override environment defaults
/// once; a second occurrence is an error, including when the two spellings are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_override_environment_once_and_leave_other_arguments() {
let mut roots = AutomationArgs::new(Some("environment".into()), None);
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_preserve_environment_roots() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--control"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+31 -23
View File
@@ -21,6 +21,7 @@ compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinar
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod automation_args;
mod clipboard;
mod control_server;
#[cfg(feature = "wgpu")]
@@ -586,7 +587,36 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
}
fn main() -> std::process::ExitCode {
// First, so every start-up warning is recorded (`logging`).
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut roots_args =
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args) {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first font scan (the app's start): the fonts font services load (#579).
@@ -596,28 +626,6 @@ fn main() -> std::process::ExitCode {
mac_fonts::install();
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
prefs_dir::install_fonts();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT");
let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT");
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from),
_ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()),
_ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
+70
View File
@@ -0,0 +1,70 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Invalid roots must exit before desktop initialization on every platform, including a CI
/// machine with no display. A regression must fail rather than hang with a window open.
fn rejects(args: &[&str], message: &str) {
let mut child = Command::new(BIN)
.args(args)
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
.env("VECTORCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
}
if Instant::now() >= deadline {
child.kill().unwrap();
break true;
}
std::thread::sleep(Duration::from_millis(10));
};
let out = child.wait_with_output().unwrap();
assert!(!timed_out, "{args:?}: desktop started or did not exit promptly");
assert!(!out.status.success(), "{args:?}");
assert!(out.stdout.is_empty(), "{args:?}: {:?}", out.stdout);
// Exact stderr excludes unrelated startup warnings or display errors that could otherwise
// make a malformed invocation fail for an unrelated reason.
assert_eq!(String::from_utf8(out.stderr).unwrap(), format!("vectorcraft: {message}\n"), "{args:?}");
}
#[test]
fn missing_empty_or_option_operands_fail_before_startup() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&["--control", "7979", flag], &format!("{flag} needs a folder"));
rejects(&[flag], &format!("{flag} needs a folder"));
rejects(&["--control", "7979", &format!("{flag}=")], &format!("{flag} needs a non-empty folder"));
rejects(&[flag, ""], &format!("{flag} needs a non-empty folder"));
for next in ["--version", "--control", "--automation-write-root", "-folder"] {
rejects(&[flag, next], &format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
}
}
#[test]
fn duplicate_roots_fail_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
}
}
#[test]
fn explicit_dash_folder_does_not_consume_version() {
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
}
+34
View File
@@ -39,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
"edit.undoGroup.begin",
"Begin Undo Group",
[],
None,
"{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)",
has_doc,
undo_group_begin
),
cmd!(
"edit.undoGroup.end",
"End Undo Group",
[],
None,
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
has_doc,
undo_group_end
),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} (the Artboard tool chosen: artboard.copy)", has_selection_or_artboard_tool, copy),
cmd!(
@@ -167,6 +185,22 @@ pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
}
fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.undoGroup.begin";
let label = match p.get("label") {
None | Some(Value::Null) => None,
Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()),
Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")),
};
s.begin_named_undo_group(label);
ok()
}
fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
s.end_undo_group(bool_or(p, "cancel", false));
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
+51
View File
@@ -87,6 +87,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
load::open
),
cmd!(
query "file.list",
"List Folder",
[],
None,
"{path} the files and folders in folder `path`, by name: what an agent may open there (held to the automation read root when one is in force) → {path, entries: [{name, kind: file|folder|link, size? (files, bytes)}], truncated (more than 5000 entries)} (#1163)",
always,
file_list
),
cmd!(
query "document.serialize",
"Serialize Document",
@@ -783,6 +792,48 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
})
}
/// The most entries `file.list` returns.
const LIST_MAX: usize = 5000;
fn file_list(_: &mut Session, p: &Value) -> Result<Value> {
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad("file.list", "missing `path`"))?;
let (entries, truncated) = list_dir(path, LIST_MAX)?;
Ok(json!({ "path": absolute_path(path), "entries": entries, "truncated": truncated }))
}
/// The entries of folder `path`, sorted by name: at most `max`, and whether there were more. A
/// link isn't followed: its target may lie outside the automation roots.
#[cfg(not(target_arch = "wasm32"))]
fn list_dir(path: &str, max: usize) -> Result<(Vec<Value>, bool)> {
may_read(path)?;
let dir = std::fs::read_dir(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut entries: Vec<(String, Value)> = vec![];
let mut truncated = false;
for e in dir.flatten() {
if entries.len() == max {
truncated = true;
break;
}
let name = e.file_name().to_string_lossy().into_owned();
let Ok(t) = e.file_type() else { continue };
let entry = if t.is_symlink() {
json!({ "name": name, "kind": "link" })
} else if t.is_dir() {
json!({ "name": name, "kind": "folder" })
} else {
json!({ "name": name, "kind": "file", "size": e.metadata().map(|m| m.len()).ok() })
};
entries.push((name, entry));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok((entries.into_iter().map(|(_, v)| v).collect(), truncated))
}
#[cfg(target_arch = "wasm32")]
fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
Err(EngineError::Other("no file system here".into()))
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
#[cfg(not(target_arch = "wasm32"))]
+43 -22
View File
@@ -26,6 +26,9 @@ use vectorcraft_render::{AntiAlias, RenderOptions};
use super::*;
mod report;
use report::FlattenReport;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"document.rasterEffectsSettings",
@@ -409,9 +412,9 @@ fn needs(n: &Node, which: Which) -> bool {
}
/// The art of `n`'s opacity mask with its raster effects as images.
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
if let Some(m) = n.mask.as_mut()
&& let Some(art) = walk(out, &m.art, which)
&& let Some(art) = walk(out, &m.art, report)
{
m.art = Arc::new(art);
}
@@ -420,15 +423,23 @@ fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
/// has none. An object whose effects all paint below it keeps its vector art over an image of
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
let checkpoint = report.checkpoint();
let result = walk_inner(out, n, report);
report.attribute_since(checkpoint, n);
result
}
fn walk_inner(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
let which = report.which;
if !needs(n, which) {
return None;
}
if !imaged(n, which) {
let mut m = n.clone();
walk_mask(out, &mut m, which);
walk_mask(out, &mut m, report);
if is_container(&m)
&& let Some(ch) = walk_all(out, n.children()?, which)
&& let Some(ch) = walk_all(out, n.children()?, report)
&& let Some(slot) = m.children_mut()
{
*slot = ch;
@@ -436,33 +447,41 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
return Some(m);
}
if let Some(g) = placed_symbol(out, n) {
return Some(walk(out, &g, which).unwrap_or(g));
return Some(walk(out, &g, report).unwrap_or(g));
}
let below = below_only(n);
if !below && let Some(g) = split_items(out, n) {
return Some(walk(out, &g, which).unwrap_or(g));
return Some(walk(out, &g, report).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let images = below_images(out, n)?;
let mut m = walk(out, &v, which).unwrap_or(v);
for image in &images {
report.record(n, image);
}
let mut m = walk(out, &v, report).unwrap_or(v);
// Each goes in first: the last one put is the lowest.
for image in images.into_iter().rev() {
put_below(out, &mut m, image);
}
m
} else {
replace_with_image(v, raster_image(out, n, false, false)?)
let image = raster_image(out, n, false, false)?;
report.record(n, &image);
replace_with_image(v, image)
};
walk_mask(out, &mut m, which);
walk_mask(out, &mut m, report);
Some(m)
}
/// `nodes` with their raster effects as images, `None` when none has any.
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], report: &mut FlattenReport) -> Option<Vec<Arc<Node>>> {
nodes
.iter()
.any(|n| needs(n, report.which))
.then(|| nodes.iter().map(|n| walk(out, n, report).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
}
/// A copy of `doc` with raster effects turned into images at the document's raster effects
@@ -470,17 +489,18 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
/// opacity masks, symbol definitions and pattern tiles.
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
flatten(doc, Which::All)
flatten(doc, &mut FlattenReport::new(Which::All, doc))
}
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
/// other raster effects live), or `None` when there are none.
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
flatten(doc, Which::Pixel)
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
}
fn flatten(doc: &Document, which: Which) -> Option<Document> {
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
let which = report.which;
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
return None;
@@ -489,18 +509,18 @@ fn flatten(doc: &Document, which: Which) -> Option<Document> {
let baked = effects::bake_document(doc);
let src = baked.as_ref().unwrap_or(doc);
let mut out = src.clone();
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
if let Some(layers) = walk_all(&mut out, &src.layers, report) {
out.layers = layers;
}
for i in 0..out.symbols.len() {
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, report))
&& let Some(s) = out.symbols.get_mut(i)
{
s.art = Arc::new(art);
}
}
for i in 0..out.patterns.len() {
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, report))
&& let Some(p) = out.patterns.get_mut(i)
{
p.art = art;
@@ -520,10 +540,11 @@ pub fn export_pdf_with_report(
doc: &Document,
opts: &vectorcraft_pdf::PdfOptions,
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
match flatten_raster_effects(doc) {
Some(d) => vectorcraft_pdf::export_with_report(&d, opts),
None => vectorcraft_pdf::export_with_report(doc, opts),
}
let mut report = FlattenReport::new(Which::All, doc);
let flat = flatten(doc, &mut report);
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
exported.warnings.extend(report.warnings());
Ok(exported)
}
#[cfg(test)]
+129
View File
@@ -0,0 +1,129 @@
//! The PDF export warnings for raster effects whose images the 64 million pixel cap rendered below
//! the document's raster effects resolution, one per object of the document.
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::Which;
pub(super) struct FlattenReport {
pub which: Which,
requested_ppi: f64,
reductions: Vec<Reduction>,
/// Ids from here on are the flattening's own (split appearance items, groups under shadows).
first_new_id: u64,
}
struct Reduction {
id: NodeId,
name: Option<String>,
achieved_ppi: f64,
}
impl FlattenReport {
pub fn new(which: Which, doc: &Document) -> Self {
Self { which, requested_ppi: super::effects_scale(doc) * 72.0, reductions: vec![], first_new_id: doc.peek_next_id() }
}
/// Note `image` (made for the raster effects of `original`) when the cap lowered its resolution.
pub fn record(&mut self, original: &Node, image: &Node) {
if let NodeKind::Image(image) = &image.kind {
let [pixel_size, ..] = image.xf.as_coeffs();
let achieved_ppi = 72.0 / pixel_size;
if achieved_ppi + 1e-6 < self.requested_ppi {
self.reductions.push(Reduction { id: original.id, name: original.name.clone(), achieved_ppi });
}
}
}
pub fn checkpoint(&self) -> usize {
self.reductions.len()
}
/// Give the reductions recorded since `checkpoint` on pieces the flattening made to `original`,
/// a document object (the innermost one, as the walk returns from it).
pub fn attribute_since(&mut self, checkpoint: usize, original: &Node) {
let first_new_id = self.first_new_id;
if original.id.0 >= first_new_id {
return;
}
for reduction in self.reductions.iter_mut().skip(checkpoint) {
if reduction.id.0 >= first_new_id {
reduction.id = original.id;
reduction.name.clone_from(&original.name);
}
}
}
pub fn warnings(self) -> Vec<String> {
let mut lowest: Vec<Reduction> = vec![];
for reduction in self.reductions {
if let Some(existing) = lowest.iter_mut().find(|r| r.id == reduction.id) {
existing.achieved_ppi = existing.achieved_ppi.min(reduction.achieved_ppi);
} else {
lowest.push(reduction);
}
}
lowest
.into_iter()
.map(|r| {
let name = r.name.map(|name| format!(" ({name})")).unwrap_or_default();
format!(
"raster effects on object {}{name}: requested {:.1} ppi, achieved {:.1} ppi due to the 64 million pixel cap",
r.id.0, self.requested_ppi, r.achieved_ppi
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::ImageObject;
use vectorcraft_geom::Affine;
fn image(ppi: f64) -> Node {
Node::new(
NodeId(999),
NodeKind::Image(ImageObject {
key: "effect".into(),
width: 8000,
height: 8000,
xf: Affine::scale(72.0 / ppi),
link: None,
placement: Default::default(),
}),
)
}
#[test]
fn lowest_resolution_is_reported_when_multiple_images_belong_to_one_object() {
let mut doc = Document::new(100.0, 100.0);
doc.raster_effects_ppi = 2400.0;
let original = &doc.layers[0];
let mut report = FlattenReport::new(Which::All, &doc);
for ppi in [1800.0, 1200.0, 1500.0] {
report.record(original, &image(ppi));
}
let warnings = report.warnings();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("achieved 1200.0 ppi"), "{:?}", warnings);
}
#[test]
fn parent_identity_is_reported_when_generated_pieces_are_attributed() {
let doc = Document::new(100.0, 100.0);
let original = &doc.layers[0];
let generated = Node::group(NodeId(998), vec![]);
let mut report = FlattenReport::new(Which::All, &doc);
report.record(&generated, &image(36.0));
report.attribute_since(0, original);
let warnings = report.warnings();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains(&format!("object {}", original.id.0)), "{:?}", warnings);
}
}
+12 -2
View File
@@ -100,6 +100,8 @@ pub struct UndoGroup {
first: Option<Arc<Document>>,
/// The journal's length when the group began: a cancelled group drops the entries after it.
journal: usize,
/// The undo step's name; else the first edit's.
label: Option<String>,
}
/// Per-document editing state.
@@ -285,13 +287,16 @@ impl DocState {
}
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
/// edit records one: the edits after it extend that step.
fn push_undo(&mut self, e: HistoryEntry) {
fn push_undo(&mut self, mut e: HistoryEntry) {
if let Some(g) = &mut self.undo_group {
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
if g.first.as_ref().is_some_and(recorded) {
return;
}
g.first = Some(e.doc.clone());
if let Some(l) = &g.label {
e.label = l.clone();
}
}
self.history.undo.push(e);
if self.history.undo.len() > self.history.limit {
@@ -1417,11 +1422,16 @@ impl Session {
/// applies each value it passes as its own command, as a typed value is applied; the drag is
/// one step.
pub fn begin_undo_group(&mut self) {
self.begin_named_undo_group(None);
}
/// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit).
pub fn begin_named_undo_group(&mut self, label: Option<String>) {
let journal = self.journal.len();
if let Some(st) = self.active_mut()
&& st.undo_group.is_none()
{
st.undo_group = Some(UndoGroup { first: None, journal });
st.undo_group = Some(UndoGroup { first: None, journal, label });
}
}
+29
View File
@@ -1670,3 +1670,32 @@ fn path_commands_reject_bad_anchors_before_modifying_geometry() {
s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2);
}
/// #1163: `file.list` lists a folder's files and folders by name, held to the automation read root
/// when one is in force.
#[test]
fn file_list_lists_a_folder_within_the_read_root() {
let base = std::env::temp_dir().join(format!("vc-file-list-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/b.svg"), "<svg/>").unwrap();
std::fs::write(base.join("in/a.pdf"), "1234").unwrap();
let path = |d: &str| base.join(d).to_string_lossy().into_owned();
let mut s = Session::new();
let r = s.execute("file.list", &json!({"path": path("in")})).unwrap();
assert_eq!(
r["entries"],
json!([{"name": "a.pdf", "kind": "file", "size": 4}, {"name": "b.svg", "kind": "file", "size": 6}, {"name": "sub", "kind": "folder"}])
);
assert_eq!(r["truncated"], false);
let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
crate::file_access::confine(Some(&roots), || {
assert!(s.execute("file.list", &json!({"path": path("in/sub")})).is_ok());
assert!(s.execute("file.list", &json!({"path": path("outside")})).is_err(), "outside the read root");
});
assert!(s.execute("file.list", &json!({"path": path("in/b.svg")})).is_err(), "not a folder");
assert!(s.execute("file.list", &json!({})).is_err());
let _ = std::fs::remove_dir_all(base);
}
+39
View File
@@ -182,3 +182,42 @@ fn shadows_and_glows_keep_their_blend_modes() {
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
}
#[test]
fn pdf_resolution_cap_warns_once_for_original_object_when_appearance_items_split() {
let mut d = Document::new(300.0, 300.0);
d.raster_effects_ppi = 2400.0;
d.raster_effects.anti_alias = false;
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 270.0, 270.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0),
);
let id = n.id;
n.name = Some("Large original".into());
for item in &mut n.appearance.items {
item.effects_mut().push(Effect { params: json!({"radius": 0.0}), ..blur() });
}
add(&mut d, n);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
let warning = &r.warnings[0];
assert!(warning.contains(&format!("object {}", id.0)) && warning.contains("Large original"), "{warning}");
assert!(warning.contains("requested 2400.0 ppi") && warning.contains("achieved 1268.7 ppi"), "{warning}");
}
#[test]
fn pdf_resolution_cap_stays_quiet_when_small_effect_meets_requested_ppi() {
let mut d = Document::new(50.0, 50.0);
d.raster_effects_ppi = 300.0;
let n = with(rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), Color::BLACK), blur());
add(&mut d, n);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
+23
View File
@@ -55,6 +55,18 @@ fn conventions_commands_batch_and_bounded_preview() {
assert_eq!(p["failed"], 1);
assert_eq!(p["completed"], total - 1);
}
// atomic: the whole batch is one undo step, named by `label` (#1163).
let undo_steps = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].as_array().map_or(0, Vec::len);
let before = undo_steps(&mut s);
let two = json!([{"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}, {"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}]);
let r = call(&mut s, "command_batch", json!({"steps":two,"atomic":true,"label":"Agent build"}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(undo_steps(&mut s), before + 1);
assert_eq!(payload(&call(&mut s, "run_command", json!({"command":"edit.undo"})))["undone"], "Agent build");
assert_eq!(undo_steps(&mut s), before);
for bad in [json!({"steps":two,"label":"x"}), json!({"steps":two,"atomic":"yes"}), json!({"steps":two,"atomic":true,"label":""})] {
assert_eq!(call(&mut s, "command_batch", bad.clone())["isError"], true, "{bad}");
}
let before = call(&mut s, "doc_inspect", json!({}));
let r = call(&mut s, "render_preview", json!({"max_side":64}));
assert_eq!(r["isError"], false, "{r}");
@@ -65,6 +77,17 @@ fn conventions_commands_batch_and_bounded_preview() {
for max in [json!(0), json!(4097), json!("64")] {
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
}
// Another artboard, three times wider than tall, previews by its index (#1163).
let r = call(&mut s, "run_command", json!({"command":"artboard.new","params":{"x":2000,"y":0,"width":300,"height":100}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, "render_preview", json!({"max_side":60,"artboard":1}));
assert_eq!(r["isError"], false, "{r}");
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!((img.width(), img.height()), (60, 20));
for board in [json!(2), json!(-1), json!("1")] {
assert_eq!(call(&mut s, "render_preview", json!({"artboard":board}))["isError"], true, "{board}");
}
for name in ["ui_inspect", "ui_screenshot"] {
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
}
+33 -11
View File
@@ -96,9 +96,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_batch",
"Run several commands",
"Run steps in order; each edit is its own undo step. A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
"Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
obj(
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step (needs an open document)"}, "label":string("The undo step's name with atomic")}),
&["steps"],
),
false,
@@ -113,8 +113,11 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"render_preview",
"Render a preview",
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
"Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"}}),
&[],
),
true,
),
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
@@ -200,7 +203,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"draw_path",
"Draw path",
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Returns the new object id.",
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.",
obj(
json!({
"points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}},
@@ -217,7 +220,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"draw_shape",
"Draw shape",
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Returns the new object id.",
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.",
obj(
json!({
"shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]},
@@ -329,7 +332,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
obj(
json!({
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
@@ -338,8 +341,8 @@ pub fn tool_definitions() -> Vec<Value> {
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"},
"range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"),
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"},
"outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"},
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"},
}),
&[],
),
@@ -902,6 +905,16 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => true,
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
};
let atomic = match a.get("atomic") {
None => false,
Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?,
};
if atomic {
let label = a.get("label").cloned().unwrap_or(Value::Null);
b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?;
} else if a.get("label").is_some() {
return Err("`label` names the undo step of an atomic batch: give atomic:true too".into());
}
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
let mut values: Vec<Value> = vec![];
@@ -936,6 +949,10 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
}
}
}
// The steps that ran stay, as one undo step.
if atomic {
b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?;
}
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results}));
r.is_error = failed > 0;
Ok(r)
@@ -946,8 +963,13 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => 1024,
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
};
let index = match a.get("artboard") {
None => 0,
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
};
let doc = b.call("document.inspect", json!({"depth":0}))?;
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
let board =
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
@@ -959,7 +981,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
if width.max(height) * scale > 4096.0 {
return Err("artboard is too large for a bounded preview".into());
}
let r = b.call("ui.render", json!({"scale":scale}))?;
let r = b.call("ui.render", json!({"scale":scale, "artboard": index}))?;
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
+3
View File
@@ -21,6 +21,9 @@ subsetter = { version = "0.2", default-features = false, features = ["variable-f
unicode-bidi = "0.3.18"
unicode-general-category = "1"
unicode-script = "0.5"
# Grapheme clusters (a Thai consonant with its vowel and tone marks, an accented letter, an emoji
# sequence) for caret movement; MIT OR Apache-2.0, already in the build.
unicode-segmentation = "1.13"
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
# Apache-2.0, already in the build through subsetter.
write-fonts = { version = "0.48", optional = true }
+15
View File
@@ -190,6 +190,21 @@ pub fn next_char(s: &str, i: usize) -> usize {
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
/// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and
/// tone marks, an accented letter or an emoji sequence is passed over whole.
pub fn prev_cluster(s: &str, i: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let i = floor_char(s, i);
GraphemeCursor::new(i, s.len(), true).prev_boundary(s, 0).ok().flatten().unwrap_or(0)
}
/// The end of the grapheme cluster at `i` (Right arrow, Delete): see [`prev_cluster`].
pub fn next_cluster(s: &str, i: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let i = floor_char(s, i);
GraphemeCursor::new(i, s.len(), true).next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\'' || c == '’'
}
+64
View File
@@ -391,6 +391,36 @@ struct SysFallback {
misses: std::collections::HashSet<char>,
}
/// Fonts made for Thai, tried first (when installed) for Thai the font in use lacks: Windows
/// (Leelawadee UI; Tahoma before Windows 8), macOS (Thonburi, Sukhumvit Set) and Linux (Noto,
/// TLWG).
pub const THAI_FAMILIES: &[&str] = &[
"Leelawadee UI",
"Leelawadee",
"Tahoma",
"Thonburi",
"Sukhumvit Set",
"Noto Sans Thai",
"Noto Sans Thai UI",
"Noto Sans Thai Looped",
"Waree",
"Loma",
"Garuda",
];
/// The families tried first for character `c`, in a script whose text only fonts made for it set
/// well (Thai, Lao, Khmer, Myanmar); none for other characters.
fn script_families(c: char) -> &'static [&'static str] {
use unicode_script::{Script, UnicodeScript};
match c.script() {
Script::Thai => THAI_FAMILIES,
Script::Lao => &["Lao UI", "Leelawadee UI", "Lao Sangam MN", "Noto Sans Lao", "Noto Sans Lao UI", "Phetsarath OT"],
Script::Khmer => &["Khmer UI", "Khmer Sangam MN", "Noto Sans Khmer", "Noto Sans Khmer UI", "Khmer OS"],
Script::Myanmar => &["Myanmar Text", "Myanmar Sangam MN", "Noto Sans Myanmar", "Noto Sans Myanmar UI", "Padauk"],
_ => &[],
}
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
@@ -1917,6 +1947,18 @@ impl FontDb {
/// First face (fallback family first, then load order) that covers `c`; on native, system
/// fonts are loaded lazily the first time no loaded face covers a character.
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
// Thai (Lao, Khmer, Myanmar) takes a font made for it, when one is installed, rather than
// whichever face covering it happened to load first (a display face, say).
let prefer = script_families(c);
if let Some(f) = self.family_fallback(c, exclude, prefer) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.load_families(c, prefer)
&& let Some(f) = self.family_fallback(c, exclude, prefer)
{
return Some(f);
}
if let Some(f) = self.loaded_fallback(c, exclude) {
return Some(f);
}
@@ -1934,6 +1976,28 @@ impl FontDb {
self.fallback_for(c, 0)
}
/// The loaded face of the first of `families` that covers `c` (upright, nearest Regular).
fn family_fallback(&self, c: char, exclude: u32, families: &[&str]) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
families.iter().find_map(|fam| {
faces
.iter()
.filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(fam) && f.covers(c))
.min_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32))
.cloned()
})
}
/// Load the first of the installed `families` not loaded yet that covers `c` (with system
/// fallback on). Returns whether one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn load_families(&self, c: char, families: &[&str]) -> bool {
if families.is_empty() || !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
return false;
}
families.iter().any(|fam| !self.is_loaded(fam) && self.load_cataloged(fam) && self.family_fallback(c, 0, &[fam]).is_some())
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
+20
View File
@@ -569,3 +569,23 @@ fn family_display_name_follows_show_font_names_in_english() {
let face = db.face("ヒラギノ角ゴシック", "Regular").unwrap();
assert_eq!(face.family, "Hiragino Sans");
}
/// Thai typed in a font without Thai takes an installed font made for Thai (Leelawadee UI,
/// Thonburi, Noto Sans Thai…), whatever other face covering Thai loaded first; Latin beside it keeps
/// the font chosen. Skipped where none of those families is installed.
#[test]
fn thai_falls_back_to_a_font_made_for_thai() {
let db = FontDb::global();
let Some(thai) = crate::fontdb::THAI_FAMILIES.iter().find(|f| db.has_family(f)) else {
eprintln!("skipped: no Thai font installed");
return;
};
let st = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() };
let l = layout(db, &TextObject::point(kurbo::Point::ZERO, "Thai ภาษาไทย", st));
let family = |g: &PositionedGlyph| db.face_by_id(g.font_id).map(|f| f.family.clone()).unwrap_or_default();
let (latin, thai_glyphs) = l.glyphs.split_at(5);
assert!(latin.iter().all(|g| family(g) == FALLBACK_FAMILY), "{:?}", latin.iter().map(family).collect::<Vec<_>>());
let first = crate::fontdb::THAI_FAMILIES.iter().find(|f| thai_glyphs.iter().all(|g| family(g).eq_ignore_ascii_case(f)));
assert!(first.is_some(), "Thai drawn in {:?}; {thai} is installed", thai_glyphs.iter().map(family).collect::<Vec<_>>());
assert!(thai_glyphs.iter().all(|g| g.gid != 0), "no .notdef");
}
+14
View File
@@ -791,3 +791,17 @@ fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
let moved = x0(&half)[1] - x0(&none)[1];
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
}
/// The arrows pass a Thai consonant with its vowel and tone marks whole (น้ำ: no, mai tho, sara am),
/// as an accented letter written with a combining mark; plain text moves a character at a time.
#[test]
fn caret_moves_over_whole_clusters() {
let s = "กน\u{0E49}\u{0E33}e\u{0301}x";
let stops: Vec<usize> = std::iter::successors(Some(0), |&i| (i < s.len()).then(|| edit::next_cluster(s, i))).collect();
assert_eq!(stops, [0, 3, 12, 15, 16]);
let back: Vec<usize> = std::iter::successors(Some(s.len()), |&i| (i > 0).then(|| edit::prev_cluster(s, i))).collect();
assert_eq!(back, [16, 15, 12, 3, 0]);
// Inside a cluster (a caret left there by a click), the arrows go to its edges.
assert_eq!((edit::prev_cluster(s, 6), edit::next_cluster(s, 6)), (3, 12));
assert_eq!((edit::next_cluster("", 0), edit::prev_cluster("ab", 99)), (0, 1));
}
+5 -3
View File
@@ -530,8 +530,10 @@ impl Tool for TypeTool {
self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) };
self.replace(cx, "", None)
}
// Backspace takes back one character (a mistyped Thai tone mark, say); Delete, the
// Left and Right arrows a whole cluster.
ToolKey::Delete if self.caret < len => {
self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_char(&text, self.caret) };
self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_cluster(&text, self.caret) };
self.replace(cx, "", None)
}
ToolKey::Left => {
@@ -549,7 +551,7 @@ impl Tool for TypeTool {
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
} else {
edit::prev_char(&text, self.caret)
edit::prev_cluster(&text, self.caret)
}
};
self.move_to(to, mods.shift)
@@ -569,7 +571,7 @@ impl Tool for TypeTool {
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
} else {
edit::next_char(&text, self.caret)
edit::next_cluster(&text, self.caret)
}
};
self.move_to(to, mods.shift)
+3
View File
@@ -2101,6 +2101,9 @@
Redefine Symbol Symbol neu definieren
Redefine {kind} Style {kind}format neu definieren
Redo Wiederholen
List Folder Ordner auflisten
Begin Undo Group Rückgängig-Gruppe beginnen
End Undo Group Rückgängig-Gruppe beenden
Redo {name} Wiederholen: {name}
Redo {} Wiederholen: {}
Reduce Colors Farben reduzieren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2152,6 +2152,9 @@
Redefine Symbol Redefinir símbolo
Redefine {kind} Style Redefinir estilo de {kind}
Redo Rehacer
List Folder Listar carpeta
Begin Undo Group Iniciar grupo de deshacer
End Undo Group Finalizar grupo de deshacer
Redo {name} Rehacer {name}
Redo {} Rehacer {}
Reduce Colors Reducir colores
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2149,6 +2149,9 @@
Redefine Symbol Redéfinir le symbole
Redefine {kind} Style Redéfinir le style de {kind}
Redo Rétablir
List Folder Lister le dossier
Begin Undo Group Commencer un groupe d’annulation
End Undo Group Terminer le groupe d’annulation
Redo {name} Rétablir {name}
Redo {} Rétablir {}
Reduce Colors Réduire les couleurs
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2152,6 +2152,9 @@
Redefine Symbol Ridefinisci simbolo
Redefine {kind} Style Ridefinisci stile {kind}
Redo Ripeti
List Folder Elenca cartella
Begin Undo Group Inizia gruppo di annullamento
End Undo Group Termina gruppo di annullamento
Redo {name} Ripeti {name}
Redo {} Ripeti {}
Reduce Colors Riduci colori
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -101,6 +101,9 @@
Edit 編集
Undo 取り消し
Redo やり直し
List Folder フォルダーの内容を一覧表示
Begin Undo Group 取り消しグループを開始
End Undo Group 取り消しグループを終了
Cut 切り取り
Copy コピー
Paste 貼り付け
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2148,6 +2148,9 @@
Redefine Symbol Переопределить символ
Redefine {kind} Style Переопределить стиль {kind}
Redo Повторить
List Folder Показать содержимое папки
Begin Undo Group Начать группу отмены
End Undo Group Завершить группу отмены
Redo {name} Повторить {name}
Redo {} Повторить {}
Reduce Colors Сократить число цветов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2144,6 +2144,9 @@
Redefine Symbol Перевизначити символ
Redefine {kind} Style Перевизначити стиль {kind}
Redo Повторити
List Folder Показати вміст теки
Begin Undo Group Почати групу скасування
End Undo Group Завершити групу скасування
Redo {name} Повторити {name}
Redo {} Повторити {}
Reduce Colors Скоротити кількість кольорів
Can't render this file because it contains an unexpected character in line 659 and column 40.
+3
View File
@@ -2077,6 +2077,9 @@
Redefine Symbol 重新定义符号
Redefine {kind} Style 重新定义{kind}样式
Redo 重做
List Folder 列出文件夹
Begin Undo Group 开始还原组
End Undo Group 结束还原组
Redo {name} 重做 {name}
Redo {} 重做 {}
Reduce Colors 减少颜色
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2130,6 +2130,9 @@
Redefine Symbol 重新定義符號
Redefine {kind} Style 重新定義{kind}樣式
Redo 重做
List Folder 列出資料夾
Begin Undo Group 開始還原群組
End Undo Group 結束還原群組
Redo {name} 重做 {name}
Redo {} 重做 {}
Reduce Colors 縮減顏色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -36,6 +36,11 @@ nowhere and Windows device names are refused. A root left out grants none of its
existing folders, or the app doesn't start; without `--control` they are ignored. See `docs/mcp.md`
› Confining file access for the details.
Each root flag may be given once; a command-line root overrides its environment default. Missing or
empty values, another option used as a value, and duplicate flags stop the desktop before it starts
any services, even without `--control`. For a folder name starting with `-`, use `--flag=-folder`
or the relative path `./-folder`.
What is confined: the requests themselves, the frames that carry input they inject (`ui.click`, `ui.key`,
`ui.text`…, so a menu item an agent clicks is confined too), the background saves and exports they start,
and window captures. The person at the keyboard is not: files opened from the command line, Finder or a
+5 -2
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Zig Zag's dialog with sliders and radio buttons, #1133; earlier: Brush Strokes landed) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (PDF raster-effect resolution-cap warnings; Zig Zag's dialog with sliders and radio buttons, #1133; earlier: Brush Strokes landed) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -30,7 +30,9 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
| SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) |
| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes |
Document Raster Effects Settings exists.
Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers
its resolution, PDF export warns once per object with the requested and the lowest achieved ppi
(`crates/engine/src/tests_pdfraster.rs`).
## Photoshop effects
@@ -72,6 +74,7 @@ rendering, Turntable, 3D export) is 45–75 h, still the largest single gap.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | PDF cap warnings identify original objects after appearance splitting and report the lowest achieved ppi; cap/rendering and scores unchanged |
| 2026-10-11 | minor | Zig Zag's dialog: Size and Ridges per segment sliders, Points as Smooth / Corner radio buttons (#1133) |
| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects |
| 2026-10-10 | minor | Initial Revolve landed (#846) |
+10 -2
View File
@@ -69,6 +69,7 @@ the suite) keep the agent to the folders you give it:
- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
library and preset saves to a path.
- `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read root like any read, so an agent can see what it was given (#1163).
- Read and write access are separate. Give the same folder twice for both; a root left out grants none
of its access (with only `--automation-read-root`, nothing can be written to disk, though `export` and
the save commands without a path still return the bytes). Without either flag nothing changes.
@@ -235,9 +236,9 @@ The older names below remain listed because existing workflows use them.
|---|---|
| `command_list` | Optional `filter`, `enabled_only`; returns the command array |
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below) |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below) |
| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024); inline PNG of the first artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
| `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
@@ -253,6 +254,9 @@ explicit `ids`.
Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`,
or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`).
For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art).
For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`.
Lines start with no fill and a visible stroke.
The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolour the active
proxy keeping each colour's model, `paint.lastColor` / `paint.lastGradient` re-apply the last solid colour or gradient,
and `paint.recent` returns the recent colours that every paint command (and the eyedropper) feeds.
@@ -286,6 +290,9 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. |
For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`).
PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text).
With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave
the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors
inside it (a path left with none leaves the selection, one with all of them is selected whole). The Lasso adds anchors
@@ -1693,6 +1700,7 @@ a pin back restores the original; every pin must be on the art's mesh, and empty
## New art
Initially (and after `paint.default`) new art has a white fill and a 1 pt black stroke.
New objects take the fill and stroke proxies (`paint.setFill` / `paint.setStroke` and the weight, whatever is
selected) on top of a template. With nothing selected, `stroke.set` / `stroke.setAdvanced` (cap, join, dashes,
arrowheads, profile…) and `graphicStyle.apply` (every fill, stroke and effect, opacity, blend mode and the link to the
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G11 and Current focus: #893 fixed, #937 open; M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@@ -56,7 +56,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 |
@@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | M9: PDF raster-effect resolution-cap warnings verified with real capped/uncapped exports; scores and hours unchanged |
| 2026-10-11 | minor | #893 (Shape Builder seams on grids of lines) fixed; G11 keeps #937 and its 8–15 h |
| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. |
| 2026-10-10 | minor | M9 row from main: initial Revolve |
+3 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -455,11 +455,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | PDF raster-effect cap reductions are reported per original object with requested/achieved ppi; scores and hours unchanged |
| 2026-10-11 | minor | Platforms: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work (#1151); score unchanged (estimated) |
| 2026-10-11 | minor | Drawing tools: Live Corners on corners between curved sides (#1144) leaves the missing list; score and hours unchanged at this rounding (estimated) |
| 2026-10-11 | minor | Type advanced: Proportional Metrics in vertical type (`vpal`, #966) leaves the missing list; score and hours unchanged at this rounding (estimated) |
+5 -1
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Proportional Metrics in vertical type, `vpal`, #966; document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -27,6 +27,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) |
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Variable fonts | partial | named instances as styles; axis sliders missing |
| Colour fonts (OpenType SVG / COLR) | partial | |
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
@@ -36,6 +37,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
| Smart Punctuation, Change Case, Find & Replace | done | |
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | |
| Touch Type tool | missing | listed in the toolbar, not implemented |
| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model |
| Snap to Glyph | missing | menu stub |
@@ -51,6 +53,8 @@ against Illustrator's public documentation; **estimated** unless noted.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The Type tool's arrows and Delete move by grapheme cluster, never between a Thai consonant and its vowel or tone marks; score unchanged (estimated) |
| 2026-10-11 | minor | Thai (Lao, Khmer, Myanmar) missing from the font in use falls back to an installed font made for the script instead of whichever face covering it loaded first; score unchanged (estimated) |
| 2026-10-11 | minor | Proportional Metrics done: `palt` in horizontal type, `vpal` in vertical type (#966, #1082); ruby and manual tate-chu-yoko still missing |
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |