From 27ab77377b02f23f104ab2826b257f302d12818b Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:27:13 +0430 Subject: [PATCH 001/126] Fix #3626: an imported document takes the title the file carries An SVG's root and a PDF's XMP dc:title (else Info Title) title the imported document; a file that gives none, or only the Untitled-N name, takes its file name without the extension. Native files and documents restored from editing data keep their own title. --- crates/engine/src/cmd/fileinfo.rs | 12 +++ crates/engine/src/cmd/fileio.rs | 1 + crates/engine/src/cmd/fileio/formats.rs | 7 +- crates/engine/src/cmd/fileio/load.rs | 22 ++++-- crates/engine/src/cmd/fileio/tests.rs | 3 +- .../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +- .../engine/src/cmd/fileio/tests_epsimport.rs | 2 +- crates/engine/src/cmd/fileio/tests_svg.rs | 2 +- crates/engine/src/tests/fileinfo.rs | 73 ++++++++++++++++++- crates/pdf/src/file_info.rs | 4 + crates/pdf/src/tests/file_info.rs | 16 +++- crates/svg/src/import.rs | 7 +- crates/svg/src/import/layers.rs | 10 +++ docs/file-format-parity.md | 3 +- docs/gaps.md | 3 +- docs/mcp.md | 17 +++-- docs/target-app-parity.md | 5 +- 17 files changed, 167 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 36ccd7cdb0..3a37098b48 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -141,6 +141,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { Ok(s.to_string()) } +/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too) +/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters +/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the +/// Untitled-N name New gives (what a document that never had a title writes to its files). +pub(crate) fn imported_title(raw: &str) -> Option<String> { + let words: Vec<String> = + raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); + let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); + let title = title.trim_end(); + super::fileio::is_own_title(title).then(|| title.to_string()) +} + /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring /// case), empty ones dropped. fn keywords(v: &Value) -> Result<Vec<String>> { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 76f408d221..cbc8506fca 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -125,6 +125,7 @@ pub use commands::specs; pub use export_names::artboard_file_names; pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; use export_names::{distinct_names_by, write_encoded}; +pub(crate) use formats::is_own_title; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 09e8de8294..30778f6a6e 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -778,7 +778,12 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String { /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + is_own_title(&doc.title) +} + +/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives? +pub(crate) fn is_own_title(title: &str) -> bool { + !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index a318616d54..8a646a8e91 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,9 +7,10 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; +use super::super::fileinfo::imported_title; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; -use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name}; +use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name}; use crate::cmd::links::Resolved; use crate::{EngineError, Prefs}; @@ -392,10 +393,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> }; // A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document // it carries, opens with the File Info it gives (a PostScript `.ai` gives none). + let mut pdf_title = None; if matches!(format.id, "pdf" | "ai" | "ait") && !restored && let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref()) { + pdf_title = info.title.clone(); super::super::fileinfo::read_pdf(&mut doc.metadata, info); } // Threaded type read from a file flows through its frames (the threaded type of a restored @@ -412,11 +415,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } - // Imports are named after the file; a native document keeps its own title (the tab shows the - // file name once it has a path). - if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { - doc.title = title; - } + // The document is titled as the file titles it: a native document keeps its own title, one + // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's + // `<title>`), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the + // document takes the file name without its extension, as an export does (#3626). The tab shows + // the file name once the document has a path. + let own = match format.id { + "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()), + _ if restored || format.id == "svg" => imported_title(&doc.title), + "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title), + _ => None, + }; + doc.title = own.unwrap_or_else(|| file_stem(&title)); warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 69158c04b8..d3714e82e8 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -67,7 +67,8 @@ fn opens_every_readable_format() { ] { let r = open(&mut s, name, &bytes); assert_eq!(r["format"], format, "{name}"); - assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}"); + // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it. + assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}"); assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through"); } for (name, kind) in [ diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs index ec7b295c23..c7de9d9e16 100644 --- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs @@ -120,7 +120,11 @@ const MM: f64 = 72.0 / 25.4; fn document_open_reads_a_dxf_drawing() { let mut s = Session::new(); let r = open(&mut s, &plan(), json!({})).unwrap(); - assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf"))); + assert_eq!( + (r["format"].as_str(), r["title"].as_str()), + (Some("dxf"), Some("plan")), + "a file that carries no title opens as its name without the extension" + ); assert_eq!(layer_names(&s), ["Walls", "Notes"]); let st = s.doc().unwrap(); // Millimetres at 1:1, the artboard the art's size. diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 8c4141902c..0e9b8e805f 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() { // The page is the bounding box: the art's bounds. assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points"); assert_eq!(objects(&s), 1); - assert_eq!(s.doc().unwrap().title(), "plain.eps"); + assert_eq!(s.doc().unwrap().title(), "plain"); } /// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 73ee604a98..ff3a73432c 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() { let doc = &s.doc().unwrap().doc; assert_eq!(doc.artboards.len(), 2, "both artboards came back"); assert_eq!(doc.artboards[1].name, "Back"); - assert_eq!(doc.title, "both.svg"); + assert_eq!(doc.title, "both"); // Without it the SVG is imported (one artboard). let text = svg(&mut s, json!({})); s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap(); diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index c37cbfe90d..905cf910f3 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -161,7 +161,7 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["description"], "A poster\nfor the fair"); assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); - assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["title"], "Poster", "the title is the one the PDF carries, not its file name"); assert_eq!(back["copyrightStatus"], "copyrighted"); assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); assert_eq!(back["copyrightUrl"], "https://example.com/rights"); @@ -277,6 +277,77 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() { assert_eq!(m.copyright_url, ""); } +/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose +/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`. +fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; + let first = first_extra(1); + let mut objects = vec![]; + let mut catalog = String::new(); + if let Some(t) = xmp_title { + let xmp = format!( + "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\ + <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\ + <dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Zugangswege</rdf:li><rdf:li xml:lang=\"x-default\">{t}</rdf:li></rdf:Alt></dc:title>\ + </rdf:Description></rdf:RDF></x:xmpmeta>" + ); + objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())); + catalog = format!("/Metadata {first} 0 R "); + } + let info_at = first + objects.len(); + if let Some(i) = info { + objects.push(format!("<< {i} >>")); + } + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None); + if info.is_none() { + return bytes; + } + // The trailer follows the cross-reference table, so a longer trailer moves no object. + String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes() +} + +/// The title `document.open` gives a file called `name` with content `bytes`. +fn opened_title(name: &str, bytes: &[u8]) -> String { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str()); + s.doc().unwrap().doc.title.clone() +} + +#[test] +fn import_uses_the_files_own_title() { + // SVG: the root's `<title>`, its white space made single spaces; not a shape's tooltip. + let svg = |inner: &str| { + format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" + ) + }; + assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths"); + assert_eq!( + opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), + "Zugangswege Rechenzentrum – Prüfdiagramm" + ); + // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone. + assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title"); + assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title"); + assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title"); + assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title"); + // A file that gives none (or a blank one): its name without the extension, in a folder too. + assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box"); + assert_eq!(opened_title("/work/box.svg", svg(" ").as_bytes()), "box"); + assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan"); + assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan"); + // The Untitled-N name New gives is no title (a document that never had one writes it). + assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan"); + // A native file keeps the title it has. + let mut s = session(); + let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap())); + let mut t = Session::new(); + t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap(); + assert_eq!(t.doc().unwrap().doc.title, "Poster"); +} + #[test] fn png_exports_carry_text_chunks() { let mut s = session(); diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 983a7dcea7..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { + /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`. + pub title: Option, /// XMP `dc:creator` (its names joined with ", "), else Info `Author`. pub author: Option, /// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`. @@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default(); let info = pdf.metadata(); let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + f.title = f.title.or_else(|| text(&info.title)); f.author = f.author.or_else(|| text(&info.author)); f.description = f.description.or_else(|| text(&info.subject)); f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty())); @@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option { let creators = get(DC, "creator"); let subjects = get(DC, "subject"); Some(FileInfo { + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), description: first(DC, "description"), keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs index 7ce2f42f47..f7c5d694b6 100644 --- a/crates/pdf/src/tests/file_info.rs +++ b/crates/pdf/src/tests/file_info.rs @@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String { } const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \ - /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)"; + /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)"; /// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00). const CREATED: i64 = 1_791_201_780; @@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Compiler manual".into()), author: Some("Grace Hopper".into()), description: Some("Cata log".into()), keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]), @@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() { fn the_xmp_packet_gives_file_info() { let body = description( "xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"", - "Ada LovelaceCharles Babbage\ + "PlakatPoster\ + Ada LovelaceCharles Babbage\ Ein PlakatA poster\nfor the fair\ fair print \ (c) 2026 Ada\ @@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Poster".into()), author: Some("Ada Lovelace, Charles Babbage".into()), description: Some("A poster\nfor the fair".into()), keywords: Some(vec!["fair".into(), "print".into()]), @@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() { assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60)); assert_eq!(f.description.as_deref(), Some("Cata log")); assert_eq!(f.modified, Some(MODIFIED)); + // The title is the packet's where it gives one (a blank `x-default` item leaves the next one), + // else the dictionary's. + assert_eq!(f.title.as_deref(), Some("Compiler manual")); + let body = + description("", " Manuel"); + let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Manuel")); } #[test] @@ -197,11 +207,13 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() { #[test] fn a_written_pdf_gives_its_file_info_back() { let mut d = Document::new(100.0, 100.0); + d.title = "Fair poster".into(); d.metadata.author = "Ada Lovelace".into(); d.metadata.description = "A poster\nfor the fair".into(); d.metadata.keywords = vec!["fair".into(), "print".into()]; let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); let f = file_info(&bytes, None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Fair poster")); assert_eq!(f.author.as_deref(), Some("Ada Lovelace")); assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair")); assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 11795b83fa..aa625de695 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN; use importer::SymbolMade; pub(super) use layers::DEFAULT_FONT_SIZE; pub(crate) use layers::SUBLAYER; -use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name}; +use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title}; pub(super) use prepass::Edits; pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; use prepass::{labels, prepass}; @@ -142,6 +142,11 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); doc.units = units.unit; + // The file's own title, when it gives one; else the document keeps the placeholder name + // `Document::new` gave it, for the caller to name after the file. + if let Some(title) = root_title(xml.root_element()) { + doc.title = title; + } let mut im = Importer { doc, warnings, diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 04e3887945..6115d2e445 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option { root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) } +/// The title the file gives root element `root`: the text of its first `` child (the one +/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `<title>` inside a shape or group is +/// that element's tooltip, not the document's), its runs of white space made single spaces. `None` +/// without one, or when it is blank. +pub(super) fn root_title(root: XNode) -> Option<String> { + let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?; + let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect(); + (!text.is_empty()).then(|| text.join(" ")) +} + /// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. pub(super) fn marked_layer(g: XNode) -> bool { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6f02703ca5..ba1d1738e4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -161,6 +161,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). | | 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. | | 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out | | 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) | diff --git a/docs/gaps.md b/docs/gaps.md index 8b707fd9f6..016703c688 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -732,6 +732,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | An imported document takes the title its file carries (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. | | 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. | | 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. | | 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. | diff --git a/docs/mcp.md b/docs/mcp.md index 0ca4e32353..4911590619 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3908,13 +3908,16 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp `copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the XMP packet. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`, -`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`, -`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP -packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`, -`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`, -`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that -`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled -name for a template). +`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`, +`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` +that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, +`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary +(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give +one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its +root `<title>` element (not a shape's or group's). A title is read with its white space made single spaces and +without control characters, at most 65,536 characters, as `file.info` takes one. A file that gives no title, a blank +one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own +title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name. `document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as `fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4370a3a0c..b544fdf895 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1172,7 +1172,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`). - Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). + - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). @@ -1565,6 +1565,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. | | 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. | | 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. | From 15993912dee1c8960a0618b3314d3b0ad9c40686 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:22:46 +0430 Subject: [PATCH 002/126] Fix #3626: .svgz keeps its title and the file-name fallback is cleaned (review) --- crates/engine/src/cmd/fileinfo.rs | 17 +++++++++++++++-- crates/engine/src/cmd/fileio/load.rs | 6 +++--- crates/engine/src/tests/fileinfo.rs | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 3a37098b48..e805757b12 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -146,11 +146,24 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { /// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the /// Untitled-N name New gives (what a document that never had a title writes to its files). pub(crate) fn imported_title(raw: &str) -> Option<String> { + let title = cleaned_title(raw); + super::fileio::is_own_title(&title).then_some(title) +} + +/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An +/// Untitled-N name stays. +fn cleaned_title(raw: &str) -> String { let words: Vec<String> = raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); - let title = title.trim_end(); - super::fileio::is_own_title(title).then(|| title.to_string()) + title.trim_end().to_string() +} + +/// The title a document without one of its own takes from its file name's `stem`: cleaned as an +/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts +/// with when nothing is left of it. +pub(crate) fn stem_title(stem: &str) -> String { + Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string()) } /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 8a646a8e91..ac46299de5 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; -use super::super::fileinfo::imported_title; +use super::super::fileinfo::{imported_title, stem_title}; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name}; @@ -422,11 +422,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> // the file name once the document has a path. let own = match format.id { "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()), - _ if restored || format.id == "svg" => imported_title(&doc.title), + _ if restored || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title), "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title), _ => None, }; - doc.title = own.unwrap_or_else(|| file_stem(&title)); + doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title))); warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) } diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index 905cf910f3..4cb511b99c 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -348,6 +348,28 @@ fn import_uses_the_files_own_title() { assert_eq!(t.doc().unwrap().doc.title, "Poster"); } +#[test] +fn compressed_svg_keeps_its_title() { + let svgz = |inner: &str| { + vectorcraft_svg::compress(&format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"/></svg>" + )) + }; + assert_eq!(opened_title("box.svgz", &svgz("<title>Access paths")), "Access paths"); + assert_eq!(opened_title("box.svgz", &svgz("")), "box"); +} + +#[test] +fn file_name_title_is_cleaned_like_an_imported_one() { + let svg = b""; + // A name `file.info` would reject (white space only) or that keeps control characters. + assert_eq!(opened_title(" .svg", svg), "Untitled-1"); + assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1"); + assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two"); + // The Untitled-N name stays the stem. + assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7"); +} + #[test] fn png_exports_carry_text_chunks() { let mut s = session(); From 99be3dca579c9de2c237445d038326372a874ac9 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:54:36 +0430 Subject: [PATCH 003/126] Fix #3626: the imported title goes into File Info only; the display name is unchanged An SVG's root and a PDF's, .ai's or .ait's XMP dc:title (else Info Title) are now DocMetadata::title, the File Info title, not Document::title. Document::title stays what it was on main: the file's name with its extension (a native file keeps its own), so the tab, the window title, Save As, the default export names and Save for Web are unchanged. - Document::info_title() is metadata.title else the document's title. file.info reports and compares it, document.inspect and the document.open reply report it as fileInfoTitle, and the writers of a title (PDF Title and XMP dc:title, SVG <title> and Dublin Core, PNG text, EPS, .ai and EMF) and has_own_title (DisplayDocTitle) use it. The PDF page-information mark and the default file names still use the document's name. - Changing the title in File Info sets the document's title as it always did and drops the one the file gave; the unchanged title the dialog sends back is no edit. - metadata.title is saved in the native format only when set (older versions ignore it) and is read back from the editing data of an SVG, PDF, .ai or EPS; a template opens untitled. - Reverts the file-stem title, the stem_title cleaning and the svgz branch that only existed to make Document::title a stem, and the five assertions that change had made. --- crates/doc/src/lib.rs | 2 +- crates/doc/src/metadata.rs | 43 ++++- crates/engine/src/cmd/fileinfo.rs | 45 +++-- crates/engine/src/cmd/fileio/encode.rs | 2 +- crates/engine/src/cmd/fileio/eps.rs | 2 +- crates/engine/src/cmd/fileio/formats.rs | 4 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 33 ++-- crates/engine/src/cmd/fileio/save.rs | 2 +- crates/engine/src/cmd/fileio/tests.rs | 3 +- .../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +- .../engine/src/cmd/fileio/tests_epsimport.rs | 2 +- crates/engine/src/cmd/fileio/tests_svg.rs | 2 +- crates/engine/src/cmd/webexport.rs | 2 +- crates/engine/src/inspect.rs | 4 +- crates/engine/src/tests/fileinfo.rs | 169 ++++++++++++------ crates/metafile/src/lib.rs | 2 +- crates/pdf/src/export/entry.rs | 6 +- crates/pdf/src/tests/file_info.rs | 10 ++ crates/svg/src/export.rs | 2 +- crates/svg/src/export/output.rs | 2 +- crates/svg/src/import.rs | 8 +- crates/svg/tests/it/title_control_chars.rs | 26 +++ docs/file-format-parity.md | 4 +- docs/gaps.md | 4 +- docs/mcp.md | 13 +- docs/target-app-parity.md | 6 +- 27 files changed, 272 insertions(+), 134 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b59f661c21..5033e52b47 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -253,7 +253,7 @@ pub struct Document { /// they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, - /// File → File Info (the title is [`Document::title`]). + /// File → File Info (its title is [`Document::info_title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, /// Effect → Document Raster Effects Settings besides the resolution diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 151365c5d2..908b649e4d 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -1,11 +1,15 @@ //! File → File Info: the document's descriptive metadata (author, description, keywords, rating, //! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The -//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved -//! before File Info existed load unchanged. +//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it +//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and +//! [`Document::info_title`] is the title File Info shows and exports write. Every field is +//! defaulted, so documents saved before File Info existed load unchanged. use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; +use crate::Document; + /// File Info → Copyright Status. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DocMetadata { + /// The title the file this document was opened from gives itself (an SVG's root `<title>`, a + /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of + /// [`Document::title`], the document's display name. `None`: the file gave none, so the + /// display name is the title. Left out of a saved document when unset, so other documents + /// save as they did. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, pub author: String, /// The author's position or job title. pub author_title: String, @@ -83,6 +94,16 @@ impl DocMetadata { } } +impl Document { + /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP + /// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from + /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the + /// default file name) is always [`Document::title`]. + pub fn info_title(&self) -> &str { + self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title) + } +} + /// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time` /// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock, /// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same @@ -191,6 +212,24 @@ mod tests { assert!(DocMetadata::default().png_text("").is_empty()); } + #[test] + fn the_info_title_is_the_files_own_title_else_the_display_name() { + let mut d = Document::new(10.0, 10.0); + d.title = "box.svg".into(); + assert_eq!(d.info_title(), "box.svg"); + d.metadata.title = Some("Access paths".into()); + assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths")); + d.metadata.title = Some(" ".into()); + assert_eq!(d.info_title(), "box.svg"); + // Saved only when set: other documents' metadata is as before, and the field comes back. + d.metadata.title = None; + assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title")); + d.metadata.title = Some("Access paths".into()); + let json = serde_json::to_string(&d.metadata).unwrap(); + assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths")); + assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None); + } + /// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative /// decimal integer; anything else leaves the clock to decide, so an export is never refused. #[test] diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index e805757b12..9a59d0f2f8 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -1,7 +1,8 @@ //! File → File Info (`file.info`): the document title and its descriptive metadata (author, //! description, keywords, rating, copyright), with the read-only created and modified dates. The -//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info -//! it gives ([`read_pdf`]). +//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]), +//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or +//! `.ai` opens with the File Info it gives ([`read_pdf`]). use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; @@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb pub(crate) fn info_json(d: &Document) -> Value { let m = &d.metadata; json!({ - "title": d.title, + "title": d.info_title(), "author": m.author, "authorTitle": m.author_title, "description": m.description, @@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { _ => return Err(bad(C, "params must be an object")), }; let d = &s.doc()?.doc; - let (mut title, mut m) = (d.title.clone(), d.metadata.clone()); + let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone()); for (k, v) in o { apply(&mut title, &mut m, k, v)?; } let d = &s.doc()?.doc; - if title != d.title || m != d.metadata { + // A title File Info changes becomes the document's own, as it always has (the tab and Save As + // offer it where the document has no file), and replaces the one its file gave. + let retitled = title != d.info_title(); + if retitled { + m.title = None; + } + if retitled || m != d.metadata { s.edit("File Info", |d, _| { - d.title = title; + if retitled { + d.title = title; + } d.metadata = m; Ok(()) })?; @@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { } /// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each -/// field that `file.info` would take (a value it refuses is left out), and the created and -/// modified dates. +/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`]) +/// as the File Info title, and the created and modified dates. pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain }); let fields = [ @@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let _ = apply(&mut title, m, k, &v); } } + m.title = f.title.as_deref().and_then(imported_title); m.created = f.created.or(m.created); m.modified = f.modified.or(m.modified); } @@ -141,29 +151,16 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { Ok(s.to_string()) } -/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too) +/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too) /// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters /// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the /// Untitled-N name New gives (what a document that never had a title writes to its files). pub(crate) fn imported_title(raw: &str) -> Option<String> { - let title = cleaned_title(raw); - super::fileio::is_own_title(&title).then_some(title) -} - -/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An -/// Untitled-N name stays. -fn cleaned_title(raw: &str) -> String { let words: Vec<String> = raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); - title.trim_end().to_string() -} - -/// The title a document without one of its own takes from its file name's `stem`: cleaned as an -/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts -/// with when nothing is left of it. -pub(crate) fn stem_title(stem: &str) -> String { - Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string()) + let title = title.trim_end(); + super::fileio::is_own_title(title).then(|| title.to_string()) } /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index b125e48a88..a3da6316c5 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -589,7 +589,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) { - vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title)) + vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title())) } else { bytes }; diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index f90dacfc71..7a71f4b2ec 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -326,7 +326,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let o = EpsOptions { region, origin, - title: doc.title.clone(), + title: doc.info_title().to_owned(), created, native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), ..set.eps.clone() diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 30778f6a6e..289f11139b 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -778,7 +778,7 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String { /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - is_own_title(&doc.title) + is_own_title(doc.info_title()) } /// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives? @@ -790,6 +790,8 @@ pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, fo let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; let mut d = doc.clone(); d.title = file_stem(path); + // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it. + d.metadata.title = None; Some(d) } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index d4820242d9..97e1258f67 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -142,7 +142,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } } } - let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text }; + let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text }; let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 335a3200d1..ba01841c68 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,10 +7,10 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; -use super::super::fileinfo::{imported_title, stem_title}; +use super::super::fileinfo::imported_title; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; -use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name}; +use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name}; use crate::cmd::links::Resolved; use crate::{EngineError, Prefs}; @@ -391,12 +391,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> }; // A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document // it carries, opens with the File Info it gives (a PostScript `.ai` gives none). - let mut pdf_title = None; if matches!(format.id, "pdf" | "ai" | "ait") && !restored && let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref()) { - pdf_title = info.title.clone(); super::super::fileinfo::read_pdf(&mut doc.metadata, info); } // Threaded type read from a file flows through its frames (the threaded type of a restored @@ -413,18 +411,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } - // The document is titled as the file titles it: a native document keeps its own title, one - // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's - // `<title>`), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the - // document takes the file name without its extension, as an export does (#3626). The tab shows - // the file name once the document has a path. - let own = match format.id { - "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()), - _ if restored || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title), - "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title), - _ => None, - }; - doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title))); + // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` + // or Info `Title`, which the readers put in File Info; the one a native document holds) is the + // File Info title, cleaned as `file.info` takes one. A file restored from the editing data it + // carries gives the title that document had, unless that is the file's name. The document's own + // name stays what it has always been: the file's name (#3626). + let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); + doc.metadata.title = doc.metadata.title.as_deref().or(held).and_then(imported_title); + // Imports are named after the file; a native document keeps its own title (the tab shows the + // file name once it has a path). + if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { + doc.title = title; + } warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) } @@ -540,6 +538,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St if template { doc.template = false; doc.title = s.next_untitled(); + doc.metadata.title = None; } // A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start // closed, its top-level layers as its groups (#1475). Ours carry their own. @@ -568,7 +567,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St st.imported_from = source_path; st.import_losses = losses; let opened = &s.documents()[index]; - let (title, file_info_title) = (opened.title(), opened.doc.title.clone()); + let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string()); Ok(super::merge( json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json(), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index cb513e3166..39b03c2752 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -674,7 +674,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res // With the artboards' background colors, as the pages draw them. let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; let o = vectorcraft_eps::AiEditingOptions { - title: doc.title.clone(), + title: doc.info_title().to_owned(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index d3714e82e8..69158c04b8 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -67,8 +67,7 @@ fn opens_every_readable_format() { ] { let r = open(&mut s, name, &bytes); assert_eq!(r["format"], format, "{name}"); - // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it. - assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}"); + assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}"); assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through"); } for (name, kind) in [ diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs index c7de9d9e16..ec7b295c23 100644 --- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs @@ -120,11 +120,7 @@ const MM: f64 = 72.0 / 25.4; fn document_open_reads_a_dxf_drawing() { let mut s = Session::new(); let r = open(&mut s, &plan(), json!({})).unwrap(); - assert_eq!( - (r["format"].as_str(), r["title"].as_str()), - (Some("dxf"), Some("plan")), - "a file that carries no title opens as its name without the extension" - ); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf"))); assert_eq!(layer_names(&s), ["Walls", "Notes"]); let st = s.doc().unwrap(); // Millimetres at 1:1, the artboard the art's size. diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 0e9b8e805f..8c4141902c 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() { // The page is the bounding box: the art's bounds. assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points"); assert_eq!(objects(&s), 1); - assert_eq!(s.doc().unwrap().title(), "plain"); + assert_eq!(s.doc().unwrap().title(), "plain.eps"); } /// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index ff3a73432c..73ee604a98 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() { let doc = &s.doc().unwrap().doc; assert_eq!(doc.artboards.len(), 2, "both artboards came back"); assert_eq!(doc.artboards[1].name, "Back"); - assert_eq!(doc.title, "both"); + assert_eq!(doc.title, "both.svg"); // Without it the SVG is imported (one artboard). let text = svg(&mut s, json!({})); s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap(); diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index af0f313ea1..f31c82e29e 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -644,7 +644,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator<Item = [u8; 3]> + '_ { /// The File Info text `s.metadata` lets into the files: `(keyword, text)`. fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> { - doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() + doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() } /// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata. diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 14df8529d7..a60ffe7b14 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -270,8 +270,8 @@ fn document_base(s: &Session) -> Value { json!({ "uid": st.uid, "title": st.title(), - // The File Info title (`file.info`), where `title` comes from the file name once the document has one. - "fileInfoTitle": d.title, + // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. + "fileInfoTitle": d.info_title(), "path": st.path, "dirty": st.is_dirty(), "revision": st.revision, diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index 4cb511b99c..249af96abc 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["description"], "A poster\nfor the fair"); assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); - assert_eq!(back["title"], "Poster", "the title is the one the PDF carries, not its file name"); + assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries"); + assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file"); assert_eq!(back["copyrightStatus"], "copyrighted"); assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); assert_eq!(back["copyrightUrl"], "https://example.com/rights"); @@ -307,67 +308,129 @@ fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> { String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes() } -/// The title `document.open` gives a file called `name` with content `bytes`. -fn opened_title(name: &str, bytes: &[u8]) -> String { +/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab +/// shows the document's name), `s` holding it. +fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() +} + +/// The document's name (its tab) and its File Info title. +fn titles(s: &mut Session) -> [String; 2] { + let info = s.execute("file.info", &json!({})).unwrap(); + [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()] +} + +/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title. +fn opened(name: &str, bytes: &[u8]) -> [String; 2] { let mut s = Session::new(); - let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); - assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str()); - s.doc().unwrap().doc.title.clone() + let r = open_named(&mut s, name, bytes); + let t = titles(&mut s); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply"); + assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title"); + t +} + +fn svg_titled(inner: &str) -> String { + format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" + ) } #[test] -fn import_uses_the_files_own_title() { - // SVG: the root's ``, its white space made single spaces; not a shape's tooltip. - let svg = |inner: &str| { - format!( - "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" - ) - }; - assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths"); +fn import_puts_the_files_own_title_in_file_info_only() { + // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the + // File Info title; the document keeps the file's name, with its extension. + assert_eq!(opened("box.svg", svg_titled("<title>\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]); assert_eq!( - opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), - "Zugangswege Rechenzentrum – Prüfdiagramm" + opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), + ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"] ); // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone. - assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title"); - assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title"); - assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title"); - assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title"); - // A file that gives none (or a blank one): its name without the extension, in a folder too. - assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box"); - assert_eq!(opened_title("/work/box.svg", svg(" ").as_bytes()), "box"); - assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan"); - assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan"); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]); + assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]); + // Compressed SVG too. + let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner)); + assert_eq!(opened("box.svgz", &svgz("Access paths")), ["box.svgz", "Access paths"]); + // A file that gives none (or a blank one) gives File Info the document's name. + assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]); // The Untitled-N name New gives is no title (a document that never had one writes it). - assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan"); - // A native file keeps the title it has. - let mut s = session(); - let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap())); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]); + // A template opens as a new untitled document, whatever title its file gave. + let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None)); + assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}"); +} + +#[test] +fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() { + let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("box.svg"); + std::fs::write(&path, svg_titled("Access paths")).unwrap(); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + // The default names of what the document exports are made from the file's name, not its title. + assert_eq!(s.doc().unwrap().doc.title, "box.svg"); + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_info_edits_a_files_own_title_and_the_document_follows() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The dialog sends back the title it was given: nothing changes (no undo step). + let before = s.doc().unwrap().revision; + let shown = s.execute("file.info", &json!({})).unwrap(); + s.execute("file.info", &shown).unwrap(); + assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit"); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); + // Another title is the document's own, as ever, and the file's is gone. + let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap(); + assert_eq!(r["title"], "Plan B"); + assert_eq!(titles(&mut s), ["Plan B", "Plan B"]); + assert_eq!(s.doc().unwrap().doc.metadata.title, None); + // Undo brings the file's title back, and the name. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); +} + +#[test] +fn the_files_own_title_is_kept_by_saves_and_written_by_exports() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The native file: its own title is the document's name, the File Info title survives. + let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()); let mut t = Session::new(); - t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap(); - assert_eq!(t.doc().unwrap().doc.title, "Poster"); -} - -#[test] -fn compressed_svg_keeps_its_title() { - let svgz = |inner: &str| { - vectorcraft_svg::compress(&format!( - "{inner}" - )) - }; - assert_eq!(opened_title("box.svgz", &svgz("Access paths")), "Access paths"); - assert_eq!(opened_title("box.svgz", &svgz("")), "box"); -} - -#[test] -fn file_name_title_is_cleaned_like_an_imported_one() { - let svg = b""; - // A name `file.info` would reject (white space only) or that keeps control characters. - assert_eq!(opened_title(" .svg", svg), "Untitled-1"); - assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1"); - assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two"); - // The Untitled-N name stays the stem. - assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7"); + open_named(&mut t, "other.vectorcraft", &native); + assert_eq!(titles(&mut t), ["box.svg", "Access paths"]); + // An SVG with the editing data it carries, reopened under another name. + let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"] + .as_str() + .unwrap() + .to_string(); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + let mut u = Session::new(); + let r = open_named(&mut u, "again.svg", svg.as_bytes()); + assert_eq!(r["restored"], true); + assert_eq!(titles(&mut u), ["again.svg", "Access paths"]); + // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer. + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf)); + assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}"); + } + // PNG text. + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths")); + // The default file names are made from the document's name. + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf"); } #[test] diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 5326e2f307..a517d516a6 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title), + Kind::Emf => emf::write(&ops, r, doc.info_title()), Kind::Wmf => wmf::write(&ops, r, &mut warnings), }; Ok(Output { bytes, warnings }) diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 6a4294c699..54d1f207a9 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -48,7 +48,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let doc = baked.as_ref().unwrap_or(doc); let pages = pages(doc, opts)?; - let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); + // The file's Title is File Info's; the page information names the document, as its tab does. + let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned())); + let name = opts.title.as_deref().unwrap_or(&doc.title); // The time of writing (none on the web) dates the editing data and the page information. let now = vectorcraft_doc::metadata::output_time(); let created_at = opts.created.or(now); @@ -86,7 +88,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); let (m, r) = (boxes.media, boxes.bleed); - let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; + let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() }; let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); // The page is the media box, y down from its top-left corner (like ours), in units of // `unit` points. diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs index f7c5d694b6..4c837ba67f 100644 --- a/crates/pdf/src/tests/file_info.rs +++ b/crates/pdf/src/tests/file_info.rs @@ -220,6 +220,16 @@ fn a_written_pdf_gives_its_file_info_back() { assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } +#[test] +fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() { + let mut d = Document::new(100.0, 100.0); + d.title = "poster.svg".into(); + d.metadata.title = Some("Fair poster".into()); + let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); + assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster")); + assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg")); +} + #[test] fn a_written_pdf_gives_its_copyright_back() { use vectorcraft_doc::CopyrightStatus; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 13c4268472..e905a7189a 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -186,7 +186,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // An artboard exported on its own is named after it (#3544), else after the document. // A name that is blank once the characters XML can't hold are dropped falls back too. let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name)); - let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title)); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title())); if !title.is_empty() { out.push_str(&w.indent(1)); out.push_str(&format!("{title}{nl}")); diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 01d33c833b..e3ad5722a1 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { let info = &doc.metadata; let mut terms = vec![("format", "image/svg+xml".to_string())]; let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index aa625de695..78e1f16a46 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -142,11 +142,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); doc.units = units.unit; - // The file's own title, when it gives one; else the document keeps the placeholder name - // `Document::new` gave it, for the caller to name after the file. - if let Some(title) = root_title(xml.root_element()) { - doc.title = title; - } + // The file's own title, when it gives one, is the document's File Info title; the document's + // name stays what `Document::new` gave it, for the caller to name after the file. + doc.metadata.title = root_title(xml.root_element()); let mut im = Importer { doc, warnings, diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs index 3318ed346f..2318eebc67 100644 --- a/crates/svg/tests/it/title_control_chars.rs +++ b/crates/svg/tests/it/title_control_chars.rs @@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() { assert!(svg.contains("kw")); assert!(svg.contains("(c) me")); } + +/// The root's `` is the File Info title of the imported document ([`Document::info_title`]); +/// its name stays what `Document::new` gave it, and a shape's `<title>` is that shape's tooltip. +#[test] +fn the_roots_title_is_the_file_info_title_not_the_documents_name() { + let svg = |inner: &str| { + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{inner}<rect width="5" height="5"><title>Tip"#) + }; + let d = import(&svg("\n Access\n paths ")).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths")); + for none in [svg(""), svg(" ")] { + let d = import(&none).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1")); + } +} + +/// Exports write the File Info title, where the document has one, in place of its name. +#[test] +fn exports_write_the_file_info_title() { + let mut d = Document::new(200.0, 200.0); + d.title = "box.svg".into(); + d.metadata.title = Some("Access paths".into()); + let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() }); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + assert!(!svg.contains("box.svg"), "{svg}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5b5101a5f2..f7ff2021c9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -166,7 +166,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). | +| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. | | 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out | | 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) | diff --git a/docs/gaps.md b/docs/gaps.md index bd4f5488f3..463fe76b82 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -733,7 +733,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | An imported document takes the title its file carries (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. | +| 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. | | 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. | | 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. | diff --git a/docs/mcp.md b/docs/mcp.md index 72bf8f8885..8c2e228bd7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3940,9 +3940,16 @@ that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or (`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its root `<title>` element (not a shape's or group's). A title is read with its white space made single spaces and -without control characters, at most 65,536 characters, as `file.info` takes one. A file that gives no title, a blank -one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own -title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name. +without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would +refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with +the document's name as its title, and a template opens with a new untitled name. +The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and +what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the +title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the +document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export +writes carry it (PDF `Title` and XMP `dc:title`, SVG `<title>` and Dublin Core, PNG text, EPS and `.ai` `%%Title`), +where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops +the title the file gave. `document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as `fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 412dbba039..23627465dc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1180,7 +1180,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`). - Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626). + - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). @@ -1573,7 +1573,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. | +| 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. | | 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. | From 3a1421e95ee5b33ab813c865482d48536183f60f Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:51:03 +0430 Subject: [PATCH 004/126] docs: restore the status lines damaged by merges (main's line plus this PR's note) --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 384ce0a882..a2a37c1ff7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x; an imported document's own title goes into File Info only; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (an imported file's own title goes into File Info only (#3626); EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public diff --git a/docs/gaps.md b/docs/gaps.md index 91d447aa27..74cc72c256 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an imported file's own title goes into File Info only (#3626); Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c353bb930..041226f90c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an imported file's own title goes into File Info only (#3626); the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence From cd11b62b74a8b50c299690007bf83d1989b9f69d Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:13:48 +0430 Subject: [PATCH 005/126] Keep authored File Info titles unchanged on native restore --- crates/engine/src/cmd/fileio/load.rs | 9 ++++----- crates/engine/src/tests/fileinfo.rs | 14 ++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 3824dbf226..26380383bd 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -522,12 +522,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` - // or Info `Title`, which the readers put in File Info; the one a native document holds) is the - // File Info title, cleaned as `file.info` takes one. A file restored from the editing data it - // carries gives the title that document had, unless that is the file's name. The document's own - // name stays what it has always been: the file's name (#3626). + // or Info `Title`, which the readers put in File Info) is separate from the document's name. + // Existing File Info is preserved exactly; only a restored document's legacy name fallback is + // cleaned as imported text. The document's name stays the file's name (#3626). let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); - doc.metadata.title = doc.metadata.title.as_deref().or(held).and_then(imported_title); + doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title)); // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index 249af96abc..daae68d86e 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -402,6 +402,20 @@ fn file_info_edits_a_files_own_title_and_the_document_follows() { assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); } +#[test] +fn restored_file_info_title_keeps_authored_spaces() { + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into()); + for format in ["vectorcraft", "svg", "pdf"] { + let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap()); + let mut reopened = Session::new(); + let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes); + assert_eq!(reply["restored"], format != "vectorcraft", "{format}"); + assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}"); + assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}"); + } +} + #[test] fn the_files_own_title_is_kept_by_saves_and_written_by_exports() { let mut s = Session::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b7b7ce089..159a53fe55 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; previously: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | | 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | From 54f9c03dfffd90ad3b0bc3241be7753cf2b6fe7d Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:32:15 +0430 Subject: [PATCH 006/126] Normalize imported SVG titles without changing restored metadata --- crates/engine/src/cmd/fileio/load.rs | 7 +++++-- crates/engine/src/tests/fileinfo.rs | 17 +++++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 26380383bd..97727c2251 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -523,8 +523,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> } // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` // or Info `Title`, which the readers put in File Info) is separate from the document's name. - // Existing File Info is preserved exactly; only a restored document's legacy name fallback is - // cleaned as imported text. The document's name stays the file's name (#3626). + // Restored File Info is preserved exactly; newly imported SVG candidates and a restored + // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626). + if !restored && matches!(format.id, "svg" | "svgz") { + doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title); + } let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title)); // Imports are named after the file; a native document keeps its own title (the tab shows the diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index daae68d86e..4d86a84b3c 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -365,6 +365,23 @@ fn import_puts_the_files_own_title_in_file_info_only() { assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}"); } +#[test] +fn imported_svg_titles_are_filtered_and_bounded() { + for (raw, expected) in [ + ("Untitled-2".to_string(), None), + ("\u{7f}".to_string(), None), + ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())), + ("λ".repeat(65_537), Some("λ".repeat(65_536))), + ] { + let svg = svg_titled(&format!("<title>{raw}")); + for format in ["svg", "svgz"] { + let name = format!("box.{format}"); + let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() }; + assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}"); + } + } +} + #[test] fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() { let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id())); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 11090d9d18..7f6407f5f0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; previously: restored File Info titles retain authored spaces, #3626; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | | 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | From 2fd50768421b688e8532d1e0fa2899961241250a Mon Sep 17 00:00:00 2001 From: makesomethingshit <junsoo1172@gmail.com> Date: Thu, 12 Nov 2026 01:42:41 +0900 Subject: [PATCH 007/126] Fix #6373: Live Paint Bucket fills faces crossed by a drag --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 37 +++++++++- crates/engine/src/tests/build.rs | 71 +++++++++++++++++++ crates/tools/src/builder/live_paint.rs | 56 ++++++++++++++- crates/tools/src/builder/tests.rs | 35 ++++++--- docs/roadmap.md | 5 +- docs/target-app-parity.md | 16 ++++- docs/ui-parity.md | 9 ++- 8 files changed, 213 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d392449247..b6a0c9c9e9 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 207182d40a..5f4b1205f3 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -272,6 +272,18 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let points = if let Some(input) = p.get("points") { + if input.as_array().is_none_or(|points| points.is_empty() || points.len() > 4096) { + return Err(bad(C, "points must contain 1 to 4096 points")); + } + let points = super::points_param(p, "points").ok_or_else(|| bad(C, "points must be [[x,y]...]"))?; + if points.first() != Some(&pt) || !points.iter().all(|q| finite(*q)) { + return Err(bad(C, "points must start at point and contain 1 to 4096 finite canvas points")); + } + Some(points) + } else { + None + }; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); let group = id_param(p, "group"); @@ -302,7 +314,30 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { .and_then(|n| b::visible_face_at(n, pt)) .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); + let mut targets = vec![fid]; + if let Some(points) = &points { + let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; + // Use the existing region-crossing probes, so even a face narrower than the distance + // between pointer events is filled. Reversing matches visible_face_at's front first. + let faces: Vec<_> = b::faces(n).into_iter().rev().filter(|f| f.visible).collect(); + let mut face_ids = vec![]; + let mut regions = vec![]; + for f in faces { + if let Some(path) = f.path_data() + && let Some(bounds) = path.bounds() + { + face_ids.push(f.id); + regions.push((po::Region { path: path.clone(), sources: vec![] }, path.to_bezpath(), bounds)); + } + } + let map = b::BuilderMap { regions, ..Default::default() }; + targets.extend(map.hits_along(points, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); + targets.sort(); + targets.dedup(); + } + for target in targets { + d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + } Ok((gid, fid)) })?; s.remember_paint(&paint); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 3ed51e3c38..b5b4f35e77 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2945,6 +2945,77 @@ fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() { assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue }); } +#[test] +fn live_paint_bucket_drag_fills_multiple_faces() { + let mut s = session(); + let g = live(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + let before = s.doc().unwrap().doc.clone(); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + // Skip the middle face between pointer events, as can happen in a fast drag. + s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + let gn = node(&s, g); + for x in [25.0, 75.0, 125.0] { + let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill, "dragged face at {x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "one Undo restores every face"); + s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "hover still highlights a face"); + assert_eq!(s.doc().unwrap().doc, before, "hover does not paint"); +} + +#[test] +fn live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel() { + for (deferred, cancel) in [(false, false), (false, true), (true, false)] { + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (50.25, 0.0), (50.25, 100.0))]; + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.prefs.real_time_drawing = !deferred; + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap(); + if cancel { + assert!(s.tool_busy()); + assert!(s.tool_claims_key(ToolKey::Escape, v)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert!(!s.tool_busy()); + assert!(!s.tool_claims_key(ToolKey::Escape, v)); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + if !cancel { + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + for x in [25.0, 50.125, 125.0] { + let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill, "deferred={deferred}, x={x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc == before, "deferred={deferred}, cancel={cancel}: original paths and selection art must be restored"); + } +} + +#[test] +fn live_paint_bucket_drag_rejects_invalid_points_atomically() { + let mut s = session(); + let g = live(&mut s); + let before = s.doc().unwrap().doc.clone(); + for points in + [json!([]), json!([[26.0, 50.0]]), json!([[25.0, 50.0], [null, 0]]), json!([[25.0, 50.0], [1e30, 0]]), json!(vec![[25.0, 50.0]; 4097])] + { + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": points})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + } +} + #[test] fn live_paint_bucket_tool_fills_face() { let mut s = session(); diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 08fca14327..4822617ad1 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -114,7 +114,8 @@ enum LpPaint { } /// Live Paint Bucket: by default, a click fills the face under the cursor with the current fill, -/// and Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or +/// and dragging fills the faces crossed in that group as one undo step (Escape cancels). +/// Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or /// stroke into the paint defaults; Alt+Shift-click samples only the color under the pointer. /// Clicking selected ordinary paths without Alt makes them a Live Paint group first. Its Tool /// Options (double-click the tool) are `paintFills` (on) and `paintStrokes` (off), `highlight` @@ -130,6 +131,8 @@ pub struct LivePaintBucketTool { pub(super) hover: LpHover, /// Where the pointer last moved, and whether Shift and Alt were held. at: Option<(Point, bool, bool)>, + /// The fill command and polyline previewed from the press's snapshot. + fill_drag: Option<(Value, Vec<Point>)>, fills: bool, strokes: bool, highlight: bool, @@ -143,6 +146,7 @@ impl Default for LivePaintBucketTool { cache: RegionCache::live_paint(), hover: LpHover::None, at: None, + fill_drag: None, fills: true, strokes: false, highlight: true, @@ -153,6 +157,22 @@ impl Default for LivePaintBucketTool { } impl LivePaintBucketTool { + fn begin_fill(&mut self, params: Value, p: Point) -> Vec<Action> { + self.fill_drag = Some((params.clone(), vec![p])); + vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)] + } + + fn drag_fill(&mut self, p: Point) -> Vec<Action> { + let Some((params, points)) = &mut self.fill_drag else { return vec![] }; + if points.last() == Some(&p) || points.len() >= 4096 { + return vec![]; + } + points.push(p); + let mut params = params.clone(); + params["points"] = json!(points.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()); + vec![Action::Preview("livePaint.fill".into(), params)] + } + /// What a click paints, Shift held or not. Shift switches Paint Fills alone to Paint Strokes /// alone and back, and switches both to Paint Fills alone. fn paints(&self, shift: bool) -> LpPaint { @@ -191,6 +211,12 @@ impl Tool for LivePaintBucketTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> { let p = ev.pos; + // A second press finishes the preceding click, including callers without a release. + if ev.kind == PointerKind::Down && self.fill_drag.take().is_some() { + let mut acts = vec![Action::Commit]; + acts.extend(self.pointer(cx, ev)); + return acts; + } match ev.kind { PointerKind::Move => { self.at = Some((p, ev.mods.shift, ev.mods.alt)); @@ -232,18 +258,42 @@ impl Tool for LivePaintBucketTool { return vec![]; } if let Some(g) = live_paint_at(cx.doc, p) { - return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; + return self.begin_fill(json!({ "group": g.0, "point": point }), p); } if self.cache.at(cx, p).is_some() { let roots = sorted_roots(cx.doc, &cx.selection.objects); - return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; + return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p); } vec![] } + PointerKind::Drag => self.drag_fill(p), + PointerKind::Up => { + let mut acts = self.drag_fill(p); + if self.fill_drag.take().is_some() { + acts.push(Action::Commit); + } + acts + } _ => vec![], } } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> { + if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] } + } + + fn busy(&self) -> bool { + self.fill_drag.is_some() + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + key == ToolKey::Escape && self.busy() + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> { + if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] } + } + fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> { lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32) } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 4e3919db89..35ef46e629 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1058,9 +1058,10 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { let ov = t.overlays(&cx); assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. })); let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); - let Action::Exec(id, v) = &acts[0] else { panic!() }; + let [Action::Begin(_), Action::Preview(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; assert_eq!(id, "livePaint.fill"); assert_eq!(v["group"], gid.0); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)), vec![Action::Commit]); let shift = Mods { shift: true, ..Default::default() }; let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift)); let Action::Exec(id, _) = &acts[0] else { panic!() }; @@ -1074,9 +1075,15 @@ fn bucket_options_choose_what_a_click_paints() { let p = paint(); let cx = cx(&d, &s, &p); let shift = Mods { shift: true, ..Default::default() }; - let fill = |x: f64| vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))]; + let fill = |x: f64| { + vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))] + }; let stroke = |x: f64| vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": gid.0, "point": [x, 25.0], "tolerance": 4.0 }))]; - let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| { + let acts = t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + t.pointer(&cx, &ev(PointerKind::Up, x, 25.0).with_mods(m)); + acts + }; let hovered = |t: &mut LivePaintBucketTool, x: f64, m: Mods| { t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m)); match t.overlays(&cx).as_slice() { @@ -1162,7 +1169,9 @@ fn bucket_options_set_the_highlight() { // Highlight off: nothing is highlighted, and a click still paints. t.set_option("highlight", &json!(false)); assert_eq!(highlight(&mut t), None); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)).len(), 1); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, _)] if id == "livePaint.fill")); + t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); t.set_option("highlight", &json!(true)); assert!(highlight(&mut t).is_some()); } @@ -1326,7 +1335,7 @@ fn bucket_on_plain_selection_makes_live_paint() { let cx = cx(&d, &s, &p); let mut t = LivePaintBucketTool::default(); let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)); - let Action::Exec(id, v) = &acts[0] else { panic!() }; + let [Action::Begin(_), Action::Preview(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; assert_eq!(id, "livePaint.fill"); assert_eq!(v["ids"], json!([a.0, b.0])); } @@ -1344,7 +1353,13 @@ fn bucket_rebuilds_its_map_for_a_new_revision_at_the_same_address() { d = two_rects_at(300.0).0; let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; let acts = t.pointer(&cx2, &ev(PointerKind::Down, 375.0, 50.0)); - assert_eq!(acts, vec![Action::Exec("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))]); + assert_eq!( + acts, + vec![ + Action::Begin("Live Paint Bucket".into()), + Action::Preview("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] })) + ] + ); } #[test] @@ -1357,7 +1372,8 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() { let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); let acts = t.pointer(&cx1, &ev(PointerKind::Down, 75.0, 50.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "livePaint.fill"), "{acts:?}"); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, _)] if id == "livePaint.fill"), "{acts:?}"); + t.pointer(&cx1, &ev(PointerKind::Up, 75.0, 50.0)); // The fill left the document as it was: the map still fits it. t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0)); assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing"); @@ -1745,8 +1761,9 @@ fn live_paint_tools_skip_hidden_faces() { let mut bucket = LivePaintBucketTool::default(); bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); - let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); - assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); + let fill = Action::Preview("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Begin("Live Paint Bucket".into()), fill]); + bucket.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); let mut sel = LivePaintSelectionTool::default(); sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); diff --git a/docs/roadmap.md b/docs/roadmap.md index 82362eb94c..cfae58c575 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (M12: Live Paint Bucket fills faces crossed in one drag (#6373); M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -150,7 +150,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | 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; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows 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; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, four generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, five generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; 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 with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), 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 and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), 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 and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | @@ -189,6 +189,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | | 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | | 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07d626e5a4..0a2d228187 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > +> Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). +> > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > > **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged. @@ -216,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de |---|---:|---:|---|---:|---| | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects with bounds-fitted gradients kept in place (#3380, deletion only; `crates/engine/tests/it/delete_path_pieces.rs`); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (five generated libraries including Game Trails, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | @@ -1969,10 +1971,22 @@ With area type selected, the Properties panel's Paragraph section and the Contro Edit › Copy and Cut put the selected ruler guides on the clipboard, with the selected objects or on their own, so they paste into another artboard or document (#3226). Pasted guides go on the layer new art goes to and are selected; with objects they move as the objects do, and alone they keep their places, while Paste in Place, in Front and in Back put them on the active artboard as they lay on theirs and Paste on All Artboards on every artboard. Guides have no format other apps read, so a Copy of guides alone empties the system clipboard, and the next Paste takes the guides rather than what another app copied before. Tests: `tests::guide_clipboard`, `tests::sysclip_guides` (ui-egui). +- **Live Paint Bucket drag (#6373):** starting on a face, a drag fills every visible face it + crosses in that group, including faces between pointer events. The existing region-crossing + calculation is reused; the gesture is one Undo step and Escape cancels it. Selected paths are + made live by the first press as before. Regression evidence: + `live_paint_bucket_drag_fills_multiple_faces` and + `live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel` cover sparse pointer events, + a 0.25 pt grid cell, making selected paths live, deferred drawing, cancellation and Undo; + `live_paint_bucket_drag_rejects_invalid_points_atomically` covers invalid command input. + Behaviour source: [Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html). + Scores and estimates unchanged. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44d0b19a4e..e2b2f0574c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -507,10 +507,17 @@ rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/ coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22). +## Live Paint Bucket + +Dragging from a face fills the visible faces crossed in that Live Paint group, even when a +narrow face lies between pointer events. Undo restores the whole drag, and Escape cancels it +before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | From d3a02f617dbb924725527ef15e076020d5f6e168 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 11:26:25 -0800 Subject: [PATCH 008/126] MCP: hidden library ids name their library in requests and are rewritten only as whole ids Follows #6608. ui.inspect shows a library loaded from outside the read roots as loaded/<file name>. A request may now name the library or its panel by that id in a panel, library or panels param, in a docking destination (anchor, before, onto, above, below) and in window.panel.layout's action: ui.set {panel} reopens the closed panel instead of floating a new one. The reply to such a request, an error included, shows the shown id. ui.inspect rewrites only whole ids, and only in the fields that hold library and panel ids, so a readable library's id that begins with a hidden one keeps its path, and status text, the command palette query and a script's report keep their text. A hidden library does not take the id of a library loaded without a path. ui.inspect also leaves out of recorded action steps the path and folder params, params named like destinationFolder and the entries of a paths list that lie outside the read roots, also inside a batch step's commands. It hides a library id holding a path written with either separator, and checks a folder preference against the roots as written, without trimming spaces. --- crates/ui-egui/src/control.rs | 231 ++++++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 183 ++++++++++++++++++++ docs/mcp.md | 18 +- 3 files changed, 405 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b02c3b6e1d..3cbe9f920c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -309,10 +309,22 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "fileDialog": app.picks.waiting_kind(), }); let ids = unreadable_library_ids(app); - hide_library_ids(&mut inspected, &ids); + for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { + if let Some(v) = inspected["ui"].get_mut(*field) { + hide_library_ids(v, &ids); + } + } + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + if let Some(sets) = inspected["ui"].get_mut("action_sets") { + swap_library_params(sets, &pairs); + } inspected } +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel +/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. +const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; + /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -322,9 +334,11 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel -/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a -/// workspace's name) is not read. When another of those ids already shows that name, the next free one +/// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. When another of those ids, or a library loaded in the session, already +/// has that id, the next free one /// of `loaded/<file name> (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that /// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { @@ -333,6 +347,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut ids); } + collect_library_params(&ui["action_sets"], &mut ids); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { collect_library_panel_ids(layout, &mut ids); @@ -343,10 +358,15 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { add_library_id(id, &mut ids); } let mut hidden = Vec::new(); - let mut taken = std::collections::BTreeSet::new(); + // The ids of the libraries loaded in the session, so that a hidden one does not take the id of + // one loaded without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let mut taken: std::collections::BTreeSet<String> = loaded.map(|info| info.id.clone()).collect(); for id in ids { let path = &id["loaded/".len()..]; - let name = vectorcraft_engine::cmd::fileio::file_name(path); + // Either separator: a path saved on another platform counts as a path here too. + let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); if name != path && !readable(path) { hidden.push((id, name)); } else { @@ -402,30 +422,81 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's /// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - if ids.is_empty() { + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + swap_library_ids(v, &pairs); +} + +/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a +/// workspace's name. [`swap_library_ids`] leaves it as written. +const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"]; + +/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]), +/// in strings and keys, except in the [`TEXT_FIELDS`]. +fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) { + if pairs.is_empty() { return; } - let hide = |s: &mut String| { - for (id, shown) in ids { - if s.contains(id.as_str()) { - *s = s.replace(id.as_str(), shown); + match v { + Value::String(s) => { + if let Some(swapped) = swap_whole_ids(s, pairs) { + *s = swapped; } } - }; - match v { - Value::String(s) => hide(s), - Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), Value::Object(fields) => { - for (mut key, mut item) in std::mem::take(fields) { - hide(&mut key); - hide_library_ids(&mut item, ids); - fields.insert(key, item); + for (key, mut item) in std::mem::take(fields) { + if TEXT_FIELDS.contains(&key.as_str()) { + fields.insert(key, item); + continue; + } + swap_library_ids(&mut item, pairs); + fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item); } } _ => {} } } +/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string, +/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends +/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed. +fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option<String> { + let mut out: Option<String> = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if !text.contains(from) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from) { + let end = at + from.len(); + let starts = at == 0 || text[..at].ends_with([':', '"', '`']); + let ends = end == text.len() || text[end..].starts_with(['"', '`']); + if at >= copied && starts && ends { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)? +fn names_a_loaded_library(v: &Value) -> bool { + match v { + Value::String(s) => s.contains("loaded/"), + Value::Array(items) => items.iter().any(names_a_loaded_library), + Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)), + _ => false, + } +} + /// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; @@ -439,9 +510,43 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } +/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in +/// `Folder` (`destinationFolder`, `searchFolder`)? +fn is_path_param(key: &str) -> bool { + matches!(key, "path" | "folder") || key.ends_with("Folder") +} + +/// The params of each step of each action of action set `set` (`ui.action_sets`). +fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<String, Value>> { + let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); + let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); + steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) +} + +/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the +/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { + params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); + if let Some(Value::Array(paths)) = params.get_mut("paths") { + paths.retain(|p| p.as_str().is_none_or(readable)); + } + for v in params.values_mut() { + leave_out_unreadable_values(v); + } +} + +/// [`leave_out_unreadable_params`] on each object `v` holds. +fn leave_out_unreadable_values(v: &mut Value) { + match v { + Value::Object(fields) => leave_out_unreadable_params(fields), + Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + _ => {} + } +} + /// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, -/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside -/// the read root. The UI state itself is not changed. +/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths +/// in recorded actions' steps outside the read root. The UI state itself is not changed. fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -453,6 +558,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { } } } + for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { + leave_out_unreadable_params(step); + } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); } @@ -468,8 +576,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { /// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) { for key in PREF_FOLDERS { - let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; + let path = written.trim(); + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { continue; } prefs.remove(key); @@ -499,7 +608,83 @@ fn modifiers(p: &Value) -> egui::Modifiers { egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } } +/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, +/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking +/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, +/// below}`) and a list of panels. +const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; + +/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is +/// translated. +const DOCKING_ACTION: &str = "action"; + +/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` +/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id +/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + if shown.is_empty() || !swap_library_params(&mut params, &shown) { + return dispatch(app, ctx, req); + } + let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + match dispatch(app, ctx, &named) { + Outcome::Done(mut reply) => { + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) + } + outcome => outcome, + } +} + +/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its +/// `to` id → whether one was. +fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool { + match params { + Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped), + Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| { + let this = match item { + Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(), + Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| { + let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some(); + this | swapped + }), + Value::Object(_) if key == DOCKING_ACTION => { + let before = item.clone(); + swap_library_ids(item, pairs); + *item != before + } + _ => swap_library_params(item, pairs), + }; + this | swapped + }), + _ => false, + } +} + +/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth: +/// a library's id or its panel's id. +fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet<String>) { + match v { + Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + if !LIBRARY_PARAMS.contains(&key.as_str()) { + collect_library_params(item, ids); + continue; + } + for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) { + add_library_id(s, ids); + collect_library_panel_ids(&json!(s), ids); + } + } + } + _ => {} + } +} + +fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index f725a0819c..f3aee6ca73 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -425,6 +425,124 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: +/// status text that is that id stays as written. +#[test] +fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { + let (mut app, tx, base) = confined("inspect-status-id"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = theirs.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/<file name> (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -519,6 +637,71 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that hold it stay as written. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + let query = format!("library:swatches:{hidden}"); + app.ui.palette_query = query.clone(); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index 33a7e2a68c..b8265bdfd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,14 +129,24 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, + a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`, + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + either separator counts as a path, also one saved on another platform. When another hidden library has the same + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both - replies. The other entries keep their order, and the app's own state is not changed. + replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep + their order, and the app's own state is not changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, + `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: + a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From 6ae7051e4e6906f6fc9f58b226f43aaffae98212 Mon Sep 17 00:00:00 2001 From: Sven Lafebre <s.lafebre@runbox.com> Date: Wed, 11 Nov 2026 21:43:23 +0100 Subject: [PATCH 009/126] Fix #5111: kerning at the caret before a line's first character moves it At the Type tool's caret, kerning goes on the character before the caret. At the start of the text there is none, so Alt+arrows, the Kerning field and Reset Tracking and Kerning did nothing; at a later paragraph's start the kerning landed on the invisible line break. CharStyle::kerning_before (kerningBefore) is the space before a character that starts a line by itself: the first character of the text, of a paragraph, or after a forced line break. Shaping gives its cluster the space and draws it that much later along the line (right to left the space is at its right; in vertical type it moves down the column). The caret at such a line's start sets it (edit::caret_kerning_target), in type.step, type.resetTracking and the Character panel; text.setRangeStyle takes it. SVG export starts the line where the character is drawn. The Eyedropper and character styles leave it alone. --- crates/doc/src/text/char_style.rs | 7 + crates/doc/src/text/tabs_lists.rs | 1 + crates/engine/src/cmd/appearance.rs | 3 + crates/engine/src/cmd/textedit.rs | 85 ++++++---- crates/engine/src/cmd/textstyles.rs | 10 +- crates/engine/src/cmd/textstyles/tests.rs | 4 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/caret_kerning.rs | 183 ++++++++++++++++++++++ crates/svg/src/export/text.rs | 11 ++ crates/svg/tests/it/text_layout.rs | 24 +++ crates/text/src/edit.rs | 22 +++ crates/text/src/harakat.rs | 2 + crates/text/src/layout.rs | 41 +++-- crates/text/src/shape.rs | 38 ++++- crates/text/src/tests.rs | 1 + crates/text/src/tests/kerning_before.rs | 121 ++++++++++++++ crates/ui-egui/src/panels/character.rs | 29 ++-- docs/mcp.md | 3 +- docs/target-app-parity.md | 6 +- docs/type-parity.md | 3 +- 20 files changed, 523 insertions(+), 72 deletions(-) create mode 100644 crates/engine/src/tests/caret_kerning.rs create mode 100644 crates/text/src/tests/kerning_before.rs diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index fd828c79af..37c76c5e3a 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -28,6 +28,12 @@ pub struct CharStyle { /// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option<f64>, + /// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by + /// itself: the first character of the text, of a paragraph, or after a forced line break. The + /// Type tool's caret before such a character kerns with it (#5111), since there is no character + /// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect. + #[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")] + pub kerning_before: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -411,6 +417,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index c4ec92b70b..d00005c779 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -356,6 +356,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d0..98eec73a3d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1430,6 +1430,9 @@ impl Picked { stroke_width: st.stroke_width, overprint_fill: st.overprint_fill, overprint_stroke: st.overprint_stroke, + // The space before a line's first character belongs to where the character is, + // not to how it looks: a sample from one doesn't move the target's (#5111). + kerning_before: st.kerning_before, ..cs.clone() }; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..773f2002b6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reset Tracking and Kerning", [], Some("Cmd+Alt+Q"), - "{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}", + "{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}", has_selection, reset_tracking ), @@ -430,6 +430,8 @@ pub(crate) struct CharChange { leading: Option<Option<f64>>, tracking: Option<f64>, kerning: Option<Option<f64>>, + /// The space before a character that starts a line by itself (`kerningBefore`). + kerning_before: Option<f64>, optical_kerning: Option<bool>, baseline_shift: Option<f64>, h_scale: Option<f64>, @@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> { impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> { - check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; + check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; check_switches( p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"], @@ -720,6 +722,7 @@ impl CharChange { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None), Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)), }, + kerning_before: num("kerningBefore"), optical_kerning: match p.get("kerning") { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true), Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false), @@ -777,6 +780,7 @@ impl CharChange { && self.leading.is_none() && self.tracking.is_none() && self.kerning.is_none() + && self.kerning_before.is_none() && self.optical_kerning.is_none() && self.baseline_shift.is_none() && self.h_scale.is_none() @@ -883,6 +887,9 @@ impl CharChange { if let Some(v) = self.kerning { st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); } + if let Some(v) = self.kerning_before { + st.kerning_before = v.clamp(-1000.0, 10000.0); + } if let Some(v) = self.optical_kerning { st.optical_kerning = v; } @@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> { s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default() } +/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it +/// came from a caret ([`type_ranges`]). +pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>); + /// The text ranges a type shortcut changes: the range of `id`, else all the text of the type /// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the /// story after Select All in threaded text, [`editing_story`]), else all the text of every selected /// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte -/// (None: nothing changes there). Each range comes with whether it came from a caret. -pub(crate) fn type_ranges( - s: &mut Session, - p: &Value, - c: &str, - at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, -) -> Result<Vec<(NodeId, usize, usize, bool)>> { +/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret. +pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> { // (text, its range; None: all of it). let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? { // An object that isn't type is refused as `ids` of it is. @@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges( let text = text_ref(s, id)?.plain_text(); let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len()))); let (a, b, caret) = if a < b { - (a, b, false) + (a, b, None) } else { match at_caret(&text, a) { - Some((a, b)) => (a, b, true), + Some((x, y)) => (x, y, Some(a)), None => continue, } }; @@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { Step::Tracking | Step::Kerning => prefs.tracking_increment, Step::BaselineShift => prefs.baseline_shift_increment, }; - // The ranges stepped. At a caret: kerning is the space after the character before it, leading - // the paragraph's; the rest wait for a selection. + // The ranges stepped. At a caret: kerning is the space after the character before it, or at + // the start of a line the space before the character after it (#5111), leading the + // paragraph's; the rest wait for a selection. let ranges = type_ranges(s, p, C, |text, a| match step { - Step::Kerning => Some((edit::prev_char(text, a), a)), + Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)), Step::Leading => { let para = edit::paragraph_at(text, a); Some((para.start, para.end)) } _ => None, })?; + // At a caret, the kerning stepped: (whether it is the space before the character, its value). let mut caret_kerning = Vec::with_capacity(ranges.len()); for &(id, a, b, caret) in &ranges { - let value = if matches!(step, Step::Kerning) && caret { - let t = text_ref(s, id)?; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)) - } else { - None + let value = match caret { + Some(c) if matches!(step, Step::Kerning) => { + let t = text_ref(s, id)?; + if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) { + Some((true, edit::style_at(&t.runs, a + 1).kerning_before)) + } else { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))) + } + } + _ => None, }; caret_kerning.push(value); } @@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?); } Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), - Step::Kerning => { - let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0)); - st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); - } + Step::Kerning => match caret_kerning.get(range_index).copied().flatten() { + Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0), + caret => { + let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v); + st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); + } + }, Step::BaselineShift => { st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?; } @@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { /// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. -/// The automatic method remains selected. +/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns +/// there: of the character before it, or at the start of a line the space before the character +/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected. fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> { - let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; + let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?; if !ranges.is_empty() { s.edit("Reset Tracking and Kerning", |d, _| { for &(id, a, b, caret) in &ranges { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - edit::style_range(&mut t.runs, a, b, |st| { - st.kerning = None; - if !caret { + let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before); + edit::style_range(&mut t.runs, a, b, |st| match before { + Some(true) => st.kerning_before = 0.0, + Some(false) => st.kerning = None, + None => { + st.kerning = None; + st.kerning_before = 0.0; st.tracking = 0.0; } }); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 89ec47b276..f63f4fe9fa 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -174,8 +174,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> { /// from a style instead leaves the text's own value alone. pub fn char_attrs(v: &CharStyle) -> Map<String, Value> { let mut m = to_attrs(v); - // A chosen glyph belongs to one character, not to a reusable character style. + // A chosen glyph belongs to one character, and the space before a line's first character to + // where it stands (#5111), not to a reusable character style. m.remove("glyphAlternate"); + m.remove("kerningBefore"); for (k, x) in [ ("font_version", json!(v.font_version)), ("fontAxes", json!(v.font_axes)), @@ -516,6 +518,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma if m.contains_key("glyphAlternate") { return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)")); } + if m.contains_key("kerningBefore") { + return Err(bad( + &c, + "`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)", + )); + } let mut st = with_attrs(&CharStyle::default(), m, &c)?; let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key); for (key, value, lo, hi) in diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index ab0c7d342e..53720c1ad9 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { /// The same for character styles: Auto kerning and the Roman baseline alignment, which the /// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to -/// one character, so it is not a reusable style attribute. +/// one character, and the space before a line's first character to where it stands (#5111), so +/// neither is a reusable style attribute. #[test] fn char_style_redefined_to_the_defaults_updates_its_text() { use vectorcraft_doc::CharAlign; @@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() { assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); let mut reusable = settable::<CharStyle>(); reusable.remove("glyphAlternate"); + reusable.remove("kerningBefore"); assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute"); s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..4fa733a6b2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -20,6 +20,7 @@ mod brushlib; mod brushsym; mod build; mod cancel; +mod caret_kerning; mod charstroke; pub(super) mod clip; mod clipboard; diff --git a/crates/engine/src/tests/caret_kerning.rs b/crates/engine/src/tests/caret_kerning.rs new file mode 100644 index 0000000000..dca3519cb1 --- /dev/null +++ b/crates/engine/src/tests/caret_kerning.rs @@ -0,0 +1,183 @@ +//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line +//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before +//! the character after it (`kerningBefore`), as one undo step, and the character moves. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, t: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") }; + (**t).clone() +} + +fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle { + vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1) +} + +/// Where glyph `byte` is drawn along its line, in text space. +fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 { + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id)); + l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4] +} + +/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`. +fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo { + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap(); + } + assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text"); + s.set_tool_option("select", &json!({"start": caret, "end": caret})); + v +} + +fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value { + s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap() +} + +#[test] +fn a_step_at_the_start_of_the_text_moves_the_first_character() { + let mut s = session(); + let id = text(&mut s, "AV"); + let inc = s.prefs.tracking_increment; + let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1)); + assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0])); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it"); + // A step of `inc` thousandths of the 20 pt em moves both letters along the line. + let moved = inc / 1000.0 * 20.0; + assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6); + // A negative step goes back towards the line's start, past it. + step_at(&mut s, id, 0, -3); + assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc); + assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 0.0); + assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6); +} + +#[test] +fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() { + let mut s = session(); + let id = text(&mut s, "AV"); + step_at(&mut s, id, 1, 1); + let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1)); + assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None)); + assert!(a.kerning.is_some(), "the pair after A"); +} + +#[test] +fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "One\nTwo"); + let t = drawn_x(&s, id, 4); + step_at(&mut s, id, 4, 1); + assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning"); + assert!(drawn_x(&s, id, 4) > t, "T moves"); + let forced = text(&mut s, "One\u{2028}Two"); + let after = "One\u{2028}".len(); + step_at(&mut s, forced, after, 1); + assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc)); +} + +#[test] +fn an_empty_line_has_nothing_to_kern() { + let mut s = session(); + let id = text(&mut s, "One\n\nTwo"); + let before = obj(&s, id).runs; + assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([])); + assert_eq!(obj(&s, id).runs, before); + // Nor does the end of text after a line break. + let end = text(&mut s, "One\n"); + assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([])); +} + +#[test] +fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap(); + step_at(&mut s, id, 0, 2); + assert_ne!(style_at(&s, id, 0).kerning_before, 0.0); + // At the caret before A only the space before A goes; A keeps its kerning after it. + caret_in(&mut s, id, 0); + s.execute("type.resetTracking", &json!({})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0)); + step_at(&mut s, id, 0, 2); + s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning"); +} + +#[test] +fn set_range_style_sets_the_space_before() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 50.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err()); +} + +/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back. +#[test] +fn alt_arrows_at_the_caret_before_the_first_character_kern_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "Hello"); + let v = caret_in(&mut s, id, 0); + let alt = Mods { alt: true, ..Mods::default() }; + s.tool_key(ToolKey::Left, alt, v).unwrap(); + assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None)); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); +} + +/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character +/// with a space before it leaves the target's first character where it was. +#[test] +fn the_eyedropper_keeps_the_targets_space_before() { + let mut s = session(); + let src = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap(); + let dst = text(&mut s, "Target"); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let st = style_at(&s, dst, 0); + assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't"); +} + +/// A character style captured from a line's first character leaves its space before out, and a +/// style can't be given one: it would space the first character of every paragraph set in it. +#[test] +fn a_character_style_leaves_the_space_before_out() { + let mut s = session(); + let id = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap(); + let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone(); + assert_eq!(attrs.get("tracking"), Some(&json!(50.0))); + assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}"); + let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err(); + assert!(err.to_string().contains("kerningBefore"), "{err}"); +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index b12811338b..5ff58a97bf 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -751,6 +751,17 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let x = g.xf.translation().x - offset; (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) } + // The space before a line's first character (`kerningBefore`) is in its advance and + // moves it along the line: a reader sets it from where it is drawn, without it. + None if !lay.vertical + && !g.rtl + && g.len > 0 + && run.style.kerning_before != 0.0 + && vectorcraft_text::edit::starts_line(&plain, g.byte) => + { + let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0); + (g.advance - before, g.origin.x + before) + } None => (g.advance, g.origin.x), }; let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index a031de7412..e8129a6922 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -109,6 +109,30 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first +/// `<tspan>` to where the character is drawn, left aligned or anchored at a centred line's centre. +#[test] +fn the_space_before_a_lines_first_character_moves_its_tspan() { + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + // 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after + // the first "H" is inside its line, so its value does nothing. + let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))]; + let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default()); + // The first <tspan> on each baseline. + let mut starts: Vec<(f64, f64)> = vec![]; + for t in tspans(&svg) { + let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap()); + if starts.last().is_none_or(|&(_, last)| last != y) { + starts.push((x, y)); + } + } + assert_eq!(starts.iter().map(|s| s.0).collect::<Vec<_>>(), vec![3.0, 3.0], "{svg}"); + // Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws. + let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default()); + let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap(); + assert!((x - 101.5).abs() < 0.01, "{svg}"); +} + /// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. #[test] fn proportional_metrics_write_palt() { diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index a4505f9596..c2838fbbe8 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize { s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) } +/// Does the character at byte `i` of `s` start a line by itself: the first character of the text, +/// of a paragraph, or after a forced line break (U+2028)? +pub fn starts_line(s: &str, i: usize) -> bool { + i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}'])) +} + +/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field, +/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it +/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it +/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character +/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before +/// the caret, so it is the space before the character after it (#5111). None at the start of an +/// empty line, where no character follows either. +pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range<usize>, bool)> { + let i = floor_char(s, i); + if !starts_line(s, i) { + return Some((prev_char(s, i)..i, false)); + } + let c = s.get(i..)?.chars().next()?; + (c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true)) +} + /// 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 { diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index b7c4f1060e..b781bd8b63 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -51,6 +51,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { leading, tracking, kerning, + kerning_before, optical_kerning, baseline_shift, h_scale, @@ -100,6 +101,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && leading == &b.leading && tracking == &b.tracking && kerning == &b.kerning + && kerning_before == &b.kerning_before && optical_kerning == &b.optical_kerning && baseline_shift == &b.baseline_shift && h_scale == &b.h_scale diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 10c44eb73e..c68a4af43a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool { /// /// It stands in glyph space as it stands in vertical point type's line space: a quarter turn /// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift -/// kept across the column and a leading space taken off moving it along the path, then moved across +/// kept across the column and a leading space taken off (or the space before a column's first +/// character) moving it along the path, then moved across /// the path as a glyph lying on its side is. fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { let cell = if takes_cell(g) { upright_cell(g) } else { advance }; let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); - let mut turn = - Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em)) + * shift + * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) + * unshift; // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on // a path that runs to the left: it turns half a turn about its cell's center. let tangent = Vec2::new(angle.cos(), angle.sin()); @@ -359,19 +362,20 @@ impl Ctx<'_> { } else { self.db.outline(&g.face, g.gid) }; - // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an - // upright one in vertical type by moving it up the column once it stands upright. + // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one + // moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an + // upright one in vertical type by moving it along the column once it stands upright. let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); - let lead = if upright { 0.0 } else { g.lead }; + let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by // right-to-left manual kerning as a glyph is. - Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf, + Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf, // Baseline shift moves the glyph across its line, and Character Rotation then turns it // about its shifted pen position, so the shift is across the line at any rotation. // Manual kerning and composition trim move that pen position along the line. None => { - Affine::translate((g.kern_shift - lead, -g.bshift)) + Affine::translate((kern_shift - lead, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy) @@ -379,7 +383,7 @@ impl Ctx<'_> { }; // The turning point in font units, which later moves of the glyph's transform keep. let pivot = match local.determinant() { - det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift), + det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift), _ => Point::ZERO, }; let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { @@ -490,9 +494,10 @@ impl Ctx<'_> { // space after the cell, and must not push the glyph off the centre line). let em = self.style_at(g.byte).size; let cell = if takes_cell(g) { upright_cell(g) } else { advance }; - // A leading space taken off moves it up the column after the turn (moving the - // turning point instead would move it across the column too). - let turn = Affine::translate((-g.lead, 0.0)) + // A leading space taken off moves it up the column after the turn, and the space + // before a column's first character down it (moving the turning point instead + // would move it across the column too). + let turn = Affine::translate((g.kern_shift - g.lead, 0.0)) * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))); m = shift * turn * unshift * m; // Its own advance across, from where the cell centres it: where the shaper set the @@ -527,10 +532,16 @@ impl Ctx<'_> { }; // Where its own width starts along its advance: the shaper's placement, a leading space // taken off and right-to-left manual kerning move the glyph along the line. An upright or - // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance. - let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an + // upright one after the space before a column's first character. + let along = { let [a, b, ..] = pre.as_coeffs(); - Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0) + a.hypot(b) + }; + let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0) + } else if upright { + Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0) } else { 0.0 }; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 3c56b2c914..e5d85d50fc 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -39,7 +39,9 @@ pub(crate) struct SGlyph { pub dy: f64, /// Right to left: the run's manual kerning, which moves the glyph right along the baseline, /// outside its Character Rotation; with whole-point advances, the difference the kerning makes - /// to the cluster's rounded advance. 0 otherwise. + /// to the cluster's rounded advance. Left to right and in vertical type: the space before a + /// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the + /// same way. 0 otherwise. pub kern_shift: f64, /// Outline scale (font units -> points). pub sx: f64, @@ -522,6 +524,20 @@ pub(crate) fn shape_runs( } } +/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of +/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its +/// cluster takes the space in its advance, as manual kerning takes the space after a character, +/// and left to right (and in vertical type) its glyphs are drawn that much later on the line +/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on +/// that side. +fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 { + if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}'])) + { + return 0.0; + } + Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0) +} + /// Line segments of a glyph outline in font units, cached for the current shaping pass. type Profile = Vec<(f64, f64, f64, f64)>; type CachedProfile = (Profile, (f64, f64)); @@ -836,7 +852,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]) +/// and the tracking, as long as the tracking keeps or drops standard /// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. @@ -849,6 +866,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { leading: _, tracking, kerning: _, + kerning_before: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -948,8 +966,9 @@ fn shaping_script(c: char) -> Script { /// /// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its /// vertical centre on the middle of the cap height raised by the inline's shift (see -/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual -/// kerning, which right to left in horizontal type also moves the art right by the value, as it +/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual +/// kerning and the space before it at a line's start ([`space_before`]). Right to left in +/// horizontal type the manual kerning also moves the art right by the value, as it /// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the /// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the /// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same @@ -983,7 +1002,8 @@ fn inline_glyph( let spacing_em = size * spacing_scale(st, vertical); let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + tracking + manual_kern; + let before = space_before(text, range.start, st, spacing_em); + let adv = width + tracking + manual_kern + before; SGlyph { face: face.clone(), gid: 0, @@ -994,7 +1014,7 @@ fn inline_glyph( tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, - kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, + kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before }, // Not used to draw (the art has its own transform), but they make the glyph's em box // its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a // character of its run's size, whatever its scale (see `layout::glyph_em`). @@ -1407,15 +1427,19 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); + let before = space_before(text, cl, gst, spacing_em); // Manual kerning spaces a character from the next one in reading order. Right to left, // that one is drawn to its left: the cluster's glyphs move right along the baseline by // the value, so that the space its last glyph's advance gains is at the cluster's left. + // The space before a line's first character moves them the other way. if level.is_rtl() && !feats.vertical { kern_shift = manual_kern; + } else { + kern_shift = before; } if last_in_cluster { tracked = gst.tracking / 1000.0 * spacing_em; - adv += tracked + manual_kern; + adv += tracked + manual_kern + before; } } out.push(SGlyph { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8934090d5a..cdd4248220 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -19,6 +19,7 @@ mod inline; mod joiners; mod justify; mod kept_layouts; +mod kerning_before; mod ligature_carets; mod path_joins; mod ruby; diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs new file mode 100644 index 0000000000..c33f3fd97a --- /dev/null +++ b/crates/text/src/tests/kerning_before.rs @@ -0,0 +1,121 @@ +//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111): +//! the first character of the text, of a paragraph or after a forced line break moves along its line +//! by the value, the rest of the line follows, and the value does nothing anywhere else. +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// `t` with `kerning_before` set on the character at `byte`. +fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec<f64> { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +#[test] +fn the_first_character_moves_along_its_line_and_the_rest_follow() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + // 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back. + let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]); + assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}"); + let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]); + assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}"); + // The line is that much longer, so a centred line moves its first character half as far. + let mut centred = plain.clone(); + centred.para.justify = Justify::Center; + let half = moves(¢red, &spaced(¢red, 0, 250.0), &[0, 2]); + assert!(near(&half, &[12.5, 12.5]), "{half:?}"); +} + +#[test] +fn it_does_nothing_on_a_character_inside_a_line() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]); + assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}"); + // Nor on the first character of a line that wrapped in area type: the line break moves. + let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left); + let wrapped = layout(db(), &area); + let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line; + assert_eq!(second_line, 1, "the second HOH wraps"); + let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]); + assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() { + let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0)); + let moved = moves(¶graphs, &spaced(¶graphs, 3, 250.0), &[0, 1, 3, 4]); + assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}"); + let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0)); + let after = "HO\u{2028}".len(); + let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]); + assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}"); + // On a line break it stays: an empty line has nothing to move. + let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0)); + let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]); + assert!(near(&moved, &[0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn it_takes_the_em_of_the_character_as_manual_kerning_does() { + // Horizontal Scale 50%: half the space, as kerning after a character takes it. + let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) }); + let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]); + assert!(near(&moved, &[12.5, 12.5]), "{moved:?}"); +} + +#[test] +fn right_to_left_the_space_is_at_the_right_of_the_first_character() { + // The first character in reading order is drawn rightmost; the space before it is on its right, + // so the two keep their distance, and the line is 25 pt longer. + let plain = TextObject::point(Point::ZERO, "אב", style(100.0)); + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left"); + let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2); + assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b)); + let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b)); +} + +#[test] +fn in_vertical_type_the_first_character_moves_down_its_column() { + // An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt) + // down the column, and stay in it. + for text in ["§§", "ab"] { + let mut plain = TextObject::point(Point::ZERO, text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + for i in 0..2 { + let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box()); + assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + } + } +} diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 5f2f96a8cc..2f834a272c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -266,8 +266,9 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits /// it is the pair kerning after the character before the caret, as Alt+←/→ steps it -/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like -/// the others: the selected range, or all the text. +/// (`type.step`), or at the start of a line the space before the character after it, where Auto +/// and Optical set no space (#5111); nowhere to set at an empty line's start. Else it is a +/// character attribute like the others: the selected range, or all the text. fn set_kerning(app: &mut VectorcraftApp, value: Value) { let Some((id, a, b)) = text_editing(app) else { format(app, json!({"kerning": value})); @@ -278,15 +279,18 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) { return; } let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return }; - let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a); - if start >= a { - return; - } + let Some((target, before)) = vectorcraft_text::edit::caret_kerning_target(&t.plain_text(), a) else { return }; end_typing(app); - app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); + let params = if before { + json!({"id": id.0, "start": target.start, "end": target.end, "kerningBefore": value.as_f64().unwrap_or(0.0)}) + } else { + json!({"id": id.0, "start": target.start, "end": target.end, "kerning": value}) + }; + app.run("text.setRangeStyle", params).ok(); } -/// The pair before the caret, in 1/1000 em, using the layout the canvas draws. +/// The kerning at the caret, in 1/1000 em, using the layout the canvas draws: the pair before it, +/// or at the start of a line the space before the character after it. fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { let (id, a, b) = text_editing(app)?; if a != b { @@ -294,11 +298,12 @@ fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { } let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; let text = t.plain_text(); - let start = vectorcraft_text::edit::prev_char(&text, a); - if start >= a { - return None; - } + let (target, before) = vectorcraft_text::edit::caret_kerning_target(&text, a)?; + let start = target.start; let style = vectorcraft_text::edit::style_at(&t.runs, start + 1); + if before { + return Some((style.kerning_before, false)); + } if let Some(v) = style.kerning { return Some((v, false)); } diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..862a05e92b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4773,7 +4773,8 @@ Pen's second anchor at (150, 431), in line with the square's centre. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and clears manual kerning in the type objects in `ids`, the Type tool's selected text or the selected type objects, keeping Optical; at a caret - it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, + it resets only the kerning there: of the character before it, or at the start of a line (of the text, a paragraph, + or after a forced line break) the space before the character after it (`kerningBefore`). `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel shows it, in the same text; at a caret it leaves the text as it is. In the app, `type.highlightKerning` (Cmd+Alt+K), `type.highlightTracking` (no default key) and `type.highlightFontName` (Cmd+Alt+Shift+F) show the Character panel and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..7b92606edb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -873,7 +873,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning of the character before it. + - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning there: of the character before it, or at the start of a line the space before the character after it (#5111). - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. - Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a layer through the New Layer dialog box. OK adds the layer above the current layer, and Cancel adds none. Cmd/Ctrl+L still adds a layer without the dialog. - F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels. @@ -1122,6 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). +- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. @@ -1981,6 +1982,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 702ecd5048..7f0ac969f2 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **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 @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | | 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | From 5c2e182388212a4fab1e070ee17a3c285b307dfe Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 12:47:49 -0800 Subject: [PATCH 010/126] MCP: a malformed object id is refused as such, not reported as missing Commands that need an object id read it with `id_param`, which returns None for a value that is not a non-negative integer, so `{"id": "5"}` or `{"id": -1}` was refused with "missing id". They now read it with `object_param`, which refuses such a value with "`id` must be a whole number", naming the key. This covers `document.node`, the `path.*` anchor and handle commands, the `text.*` range and path commands, the mesh, envelope and slice point commands, `layer.target`, `layer.move`, `node.move`, `select.magicWand`, and the `source`, `target`, `group` and `path` keys of `appearance.transfer`, `appearance.copyFrom`, the `livePaint.*` commands and `text.createInPath`. The optional `group` of `livePaint.fill` and `parent` of `node.move` are refused the same way when given. Before, a malformed `parent` moved the object to the top level and a malformed `group` fell back to the selection. --- crates/engine/src/cmd/appearance.rs | 10 +-- crates/engine/src/cmd/buildcmds.rs | 4 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 13 +-- crates/engine/src/cmd/distortcmds.rs | 10 +-- crates/engine/src/cmd/draw2.rs | 4 +- crates/engine/src/cmd/draw2/anchors.rs | 10 +-- crates/engine/src/cmd/draw2/cut.rs | 4 +- crates/engine/src/cmd/draw2/freehand.rs | 9 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/layer.rs | 19 +++-- crates/engine/src/cmd/live.rs | 2 +- crates/engine/src/cmd/live/envelope.rs | 4 +- crates/engine/src/cmd/live/mesh.rs | 6 +- crates/engine/src/cmd/path.rs | 16 ++-- crates/engine/src/cmd/slices.rs | 4 +- crates/engine/src/cmd/textedit.rs | 20 ++--- crates/engine/src/cmd/typecmd.rs | 10 +-- crates/engine/src/cmd/wand.rs | 4 +- crates/engine/src/tests/params.rs | 84 +++++++++++++++++++ docs/mcp.md | 8 +- 20 files changed, 172 insertions(+), 75 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d0..7f6f8cca12 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -32,8 +32,8 @@ use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ - CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, - ok, paint_targets, str_param, switch_or, targets, + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, + object_param, ok, paint_targets, str_param, switch_or, targets, }; use crate::EngineError; @@ -726,8 +726,8 @@ fn clear(n: &mut Node) { fn transfer(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "appearance.transfer"; - let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?; + let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?; if source == target { return Err(bad(C, "`source` and `target` are the same object")); } @@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> { /// Sample enabled attributes, refusing object transparency that a text range cannot store. fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "appearance.copyFrom"; - let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; + let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); let defaults_only = switch_or(p, "defaultsOnly", false, C)?; if defaults_only && (reverse || append || p.get("ids").is_some()) { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d392449247..055bd748af 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids, - number_or, point_param, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or, + object_param, point_param, str_param, switch_or, }; mod live_paint; diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 207182d40a..31cd4eaf40 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, - is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, + json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -274,7 +274,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); - let group = id_param(p, "group"); + let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; let (g, face) = s.edit("Live Paint Bucket", |d, sel| { let gid = match group { @@ -312,7 +312,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "livePaint.strokeEdge"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; @@ -335,7 +335,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { } pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let g = match object_param(p, "group", "livePaint.info")? { + Some(g) => g, + None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?, + }; let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; let faces: Vec<Value> = b::faces(n) diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 17638477a9..2dbb7a9a18 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, - matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param, + named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets, }; use crate::EngineError; @@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool { fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.set"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?); if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { @@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> { fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.copy"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); // When the profile comes out as it was (the copy replaces a side of a discontinuous point that has // its widths already), the document and its undo history stay as they are. @@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> { fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.remove"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let indices = match (p.get("indices"), p.get("index")) { (Some(a), _) => point_indices(a), (None, Some(i)) => point_indices(&json!([i])), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 7d1c1208d8..d469c7a613 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -18,8 +18,8 @@ use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, - switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok, + str_param, switch_or, }; use crate::EngineError; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index dbca59b00d..10254f3cd3 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,7 +1,7 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; use super::{ Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, - add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, + add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or, }; // ---------- Curvature ---------- @@ -41,7 +41,7 @@ enum CurvatureOp { pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> { use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); check_numbers(p, &["x", "y", "t"], C)?; let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { @@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let curvature = switch_or(p, "curvature", false, C)?; @@ -220,7 +220,7 @@ fn remove_anchors_of( pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let smooth = str_param(p, "to") == Some("smooth"); @@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let seg = usize_req(p, "segment", C)?; let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 126fa57a1b..645c184b14 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -4,7 +4,7 @@ use super::common::{ }; use super::{ Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; /// Split a filled path into connected pieces (each outer contour with its holes). @@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn( pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let anchor = usize_opt(p, "anchor", C)?; let seg = usize_opt(p, "segment", C)?; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 660f52121e..f13b0a0ba4 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -4,7 +4,8 @@ use super::common::{ }; use super::{ Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, - Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, + Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param, + switch_or, }; // ---------- Pencil / Paintbrush ---------- @@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?; let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; @@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). let connect = match p.get("connect").filter(|v| !v.is_null()) { Some(cn) => { - let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?; if other == id { return Err(bad(C, "connect needs a path other than the one extended")); } @@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({ "id": id.0 })); } if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?; let within = number_or(rd, "within", 12.0, C)?; if !within.is_finite() || within < 0.0 { return Err(bad(C, "redraw within must be a finite distance of 0 or more")); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a8a91f0af..fe683bfa9b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param, - f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, - index, ok, point_param, str_param, switch_or, + f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, + object_param, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> { } fn doc_node(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; + let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?; let mut opts = slice_opts(p, "document.node")?; let summary = bool_or(p, "summary", false); if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 49b1015bf4..593d325460 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, id_param, ids_param, index, ok, str_param, switch_or, + has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> { /// The Layers panel's target circle. fn target(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.target"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; if !st.doc.is_editable(id) { @@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> { fn set_current(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.setCurrent"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc_mut()?; let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?; st.active_layer = Some(layer); @@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool { /// The selection column: select a row's art. fn select_art(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.selectAll"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let add = switch_or(p, "add", false, C)?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; @@ -747,9 +747,12 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = match ids_param(p, "ids", C)? { + Some(ids) => ids, + None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?], + }; ids.truncate(MAX_ROWS); - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { "above" => Place::Above, "below" => Place::Below, @@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> { fn move_node(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "node.move"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let parent = id_param(p, "parent"); + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; + let parent = object_param(p, "parent", C)?; let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index); let d = &s.doc()?.doc; let n = d.node(id).ok_or(EngineError::NoNode(id))?; diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 464f241159..618865cede 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,7 +21,7 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi, - has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param, + has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param, }; mod blend; diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..3412023280 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr use super::mesh::{handle_param, index_param, whole}; use super::{ Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, - PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -419,7 +419,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 70c2d9f348..187d2149f2 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec use super::envelope::{outline_of, rows_cols}; use super::{ Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, - check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, + check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param, }; // ---------- Gradient mesh ---------- @@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh /// envelope's grid too (see [`edit_grid`]). fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> { - let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) } @@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20; /// points}…]}`. Each mesh comes once, with its points in order and without repeats. fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> { let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> { - let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; let points = match q.get("points") { None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), Some(Value::Array(v)) if v.len() <= MAX_POINTS => v diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 712ca8a179..518b2062f8 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, + has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< } fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; + let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { @@ -204,7 +204,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> { /// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor. fn close(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let h_in = match p.get("in").filter(|h| !h.is_null()) { None => None, @@ -297,7 +297,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } fn set_handle(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; + let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); @@ -315,7 +315,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> { /// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point /// tool's Shift-drag). fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?; + let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); @@ -335,7 +335,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> { } fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?; + let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; let data = PathData::new( subs.iter() @@ -1556,7 +1556,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz } fn reshape(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; + let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?; let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); @@ -1678,7 +1678,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> { } fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; + let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 06ec067934..787d48e441 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin use vectorcraft_geom::{Rect, shapes}; use super::{ - CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ids_param, index, + CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, ids_param, index, object_param, switch_or, targets, }; @@ -679,7 +679,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> { fn set_rect(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.slice.setRect"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let r = rect_param(p, C)?; let d = &s.doc()?.doc; if d.slice(id).is_none() { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..4bcbd6c932 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -11,8 +11,8 @@ use super::menucmds::whole_or; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index, + number_or, object_param, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec<CommandSpec> { @@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { fn edit_range(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.editRange"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len, C)?; @@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId fn start_list(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.startList"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let at = offset_or(p, "at", len, C)?.min(len); @@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> { } fn get_range(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; + let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?; if switch_or(p, "story", false, "text.getRange")? { return get_story(s, p, id); } @@ -1254,7 +1254,7 @@ fn drawn_glyph( fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setGlyphAlternate"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let gid = match p.get("gid") { None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")), Some(Value::Null) => None, @@ -1344,7 +1344,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.glyphAlternates"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); @@ -1359,7 +1359,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> { fn touch_type(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.touchType"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; check_numbers(p, &["start"], C)?; let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?; if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -1585,7 +1585,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> { fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.createInPath"; - let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; + let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; let on_path = match str_param(p, "mode").unwrap_or("area") { "area" => false, "onPath" | "path" => true, @@ -1975,7 +1975,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> { fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.discardEmpty"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; if edit::runs_len(&text_ref(s, id)?.runs) > 0 { return Ok(json!({"removed": false})); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d29550fa0..bf5de3a0cb 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, + ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec<CommandSpec> { @@ -1222,7 +1222,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { }; let mut items = Vec::with_capacity(raw.len()); for it in raw { - let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(it.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1253,7 +1253,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.reshapePath"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1284,7 +1284,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setPathHandle"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index c79ec0b86e..218871972f 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::select::selectable_ids; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param}; /// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in /// points, opacity in percent. @@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> { } fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?; + let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?; let st = s.doc()?; let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); let w = s.menu.wand.clone(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index b0345db4ff..6850715b0e 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() { } } +/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such, +/// naming its key, not as a missing id; an id left out is still a missing one. +#[test] +fn a_malformed_object_id_is_not_reported_as_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + let points = json!([[0, 0], [10, 10], [20, 0]]); + for bad in [json!("5"), json!(-1)] { + let id = || json!({ "id": bad }); + let with = |extra: serde_json::Value| { + let mut p = id(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + for (cmd, p, key) in [ + ("stroke.widthPoint.set", id(), "id"), + ("stroke.widthPoint.copy", id(), "id"), + ("stroke.widthPoint.remove", id(), "id"), + ("path.curvatureEdit", id(), "id"), + ("path.removeAnchor", id(), "id"), + ("path.convertAnchor", id(), "id"), + ("path.reshapeSegment", id(), "id"), + ("path.split", id(), "id"), + ("path.appendAnchor", id(), "id"), + ("path.close", id(), "id"), + ("path.setHandle", id(), "id"), + ("path.moveAnchor", id(), "id"), + ("path.setAnchors", id(), "id"), + ("path.reshape", id(), "id"), + ("path.insertAnchor", id(), "id"), + ("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"), + ("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"), + ("document.node", id(), "id"), + ("layer.target", id(), "id"), + ("layer.setCurrent", id(), "id"), + ("layer.selectAll", id(), "id"), + ("layer.move", with(json!({"target": 1})), "id"), + ("layer.move", json!({"ids": [1], "target": bad}), "target"), + ("node.move", id(), "id"), + ("node.move", json!({"id": 1, "parent": bad}), "parent"), + ("text.reshapeArea", with(json!({"anchors": []})), "id"), + ("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"), + ("text.reshapePath", id(), "id"), + ("text.setPathHandle", id(), "id"), + ("text.editRange", id(), "id"), + ("text.startList", id(), "id"), + ("text.getRange", id(), "id"), + ("text.setGlyphAlternate", id(), "id"), + ("text.glyphAlternates", id(), "id"), + ("text.touchType", id(), "id"), + ("text.discardEmpty", id(), "id"), + ("text.createInPath", json!({"path": bad}), "path"), + ("object.envelope.setMeshPoint", id(), "id"), + ("object.mesh.setPointColor", with(json!({"index": 0})), "id"), + ("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"), + ("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"), + ("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"), + ("object.mesh.deletePoint", with(json!({"index": 0})), "id"), + ("object.slice.setRect", id(), "id"), + ("appearance.transfer", json!({"source": bad, "target": 1}), "source"), + ("appearance.transfer", json!({"source": 1, "target": bad}), "target"), + ("appearance.copyFrom", json!({"source": bad}), "source"), + ("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.info", json!({"group": bad}), "group"), + ("livePaint.info", id(), "id"), + ("select.magicWand", id(), "id"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + // `layer.move` checks an `id` that stands in for `ids` before it runs. + let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id")); + assert!(named, "{cmd} {p}: {e}"); + } + } + for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("missing"), "{cmd}: {e}"); + } + assert_eq!(undo(&s), before); +} + /// A command that takes the keys of one that isn't checked yet isn't checked either. #[test] fn a_reference_to_an_unchecked_command_is_unchecked() { diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..892541c6a8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -430,7 +430,13 @@ or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseT counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and -change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +change nothing; an omitted or null `id` keeps the default. Commands that need an object id (`document.node`, the +`path.*` anchor and handle commands, `text.reshapePath`, `text.editRange`, the `object.mesh.*` point commands, +`layer.target`, `node.move`, `select.magicWand` and the others like them, and the `source`, `target`, `group` and +`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and +`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the +key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of +`node.move` are refused the same way when given; left out, they keep their defaults. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, From 42ba91fb6d27fc1936fbe21c0adb8f8de806bcbf Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 12:49:41 -0800 Subject: [PATCH 011/126] Plug-ins: the failed delete of grants.json is tested on every platform A test-only switch makes removing grants.json fail on the test's thread while the file still reads. The new test replaces an approved plug-in with one that asks for no permissions while the switch is on, and checks that the store gets the approved module back and keeps the grant unchanged. The Windows test that holds the file open without delete sharing stays. --- crates/engine/src/store.rs | 13 ++++++++++++- crates/engine/src/tests/plugins.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 01ebc46526..3bc092d06b 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -190,12 +190,23 @@ fn lock_disabled(_: &Path) -> Option<std::fs::File> { thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; + /// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when + /// another program holds it open. + pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; } /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. fn take(dir: &Path, file: &str) -> bool { let path = dir.join(file); - match std::fs::remove_file(&path) { + #[cfg(test)] + let removed = if file == GRANTS_FILE && GRANTS_DELETE_FAILS.get() { + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + } else { + std::fs::remove_file(&path) + }; + #[cfg(not(test))] + let removed = std::fs::remove_file(&path); + match removed { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { log::warn!("can't remove {}: {e}", path.display()); false diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a4be6e554f..cfaf554df7 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -2000,6 +2000,35 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// When the last grant can't be removed because `grants.json` can't be deleted, a replacing module +/// that asks for no permissions doesn't stay in the store: the store gets back the module the grant +/// still covers, and the grant is left as it was. +#[test] +fn a_grants_file_whose_deletion_fails_keeps_the_stored_module() { + let folder = crate::tests_links::Folder::new("plugin-store-undeletable-grants"); + let id = "test.engine.permissions.undeletablegrants"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let grants = folder.0.join("grants.json"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); + let file = s.stored_plugin_file(id).unwrap(); + let recorded = std::fs::read(&grants).unwrap(); + + crate::store::GRANTS_DELETE_FAILS.set(true); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); + crate::store::GRANTS_DELETE_FAILS.set(false); + assert_eq!(std::fs::read(&file).unwrap(), approved, "the store gets the module the grant covers back"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant stays as it was"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], false, "the grant still covers the module"); + + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!grants.exists(), "a removal that can delete the file forgets the grant"); +} + /// A plug-in folder that leaves a module listing no permissions under an id forgets the process's /// grant for it, unless a later file in the folder leaves a granted module there. A grant kept in /// the plug-in store stays, with the stored module it covers. From b426822a9cf33461bc4fcbfac261e39efcea66b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:06:51 -0800 Subject: [PATCH 012/126] Type: Release Type on a Path keeps the size Dynamic Text drew the type at Release Type on a Path copied the stored runs into point type with the Dynamic Text flag still set, so the text dropped back to its stored size and the file saved the flag on point type. The point type now gets the drawn sizes and the flag is turned off. The legacy .ai writer writes Dynamic Text type at its drawn sizes; it now also warns that the setting itself is not written, as the editing data writer does, whether the type is written as text or as outlines. docs/target-app-parity.md lists reshaping or scaling the path among VectorCraft's own rules, and a task bar comment marks leaving the item out for threaded type as VectorCraft's own rule. --- crates/engine/src/cmd/addons/typetools.rs | 11 ++++++--- crates/engine/src/tests/dynamic_text.rs | 20 +++++++++++++++ crates/eps/src/legacy_ai.rs | 1 + crates/eps/src/legacy_ai/tests.rs | 30 +++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 8 ++++-- crates/ui-egui/src/canvas/task_bar.rs | 3 ++- docs/mcp.md | 12 +++++---- docs/target-app-parity.md | 2 +- 8 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index bc1d4e4f25..537e6bfe75 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -21,7 +21,7 @@ use super::super::menucmds::whole_or; use super::super::object::choice; use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; use super::super::typecmd::refresh_bounds; -use super::super::typemenu::text_ids; +use super::super::typemenu::{bake_dynamic_text, text_ids}; use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets}; use crate::{EngineError, Result, Session}; @@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> { /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the -/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for -/// other type, or when it can't be placed. +/// object's own text space, so its transform stays). Type on a Path Options are dropped, and +/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it +/// can't be placed. fn released_text(t: &TextObject) -> Option<TextObject> { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); @@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option<TextObject> { (p.x.is_finite() && p.y.is_finite()).then_some(p) })?; let mut out = t.clone(); + // Point type has no brackets to fit: it keeps the sizes the path drew it at. + bake_dynamic_text(&mut out); out.kind = TextKind::Point; out.path_effect = Default::default(); out.path_align = Default::default(); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 80ad1f1af9..f9d21a6404 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -238,6 +238,26 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +#[test] +fn release_type_on_a_path_keeps_the_drawn_size() { + let mut s = session(); + let id = on_line(&mut s, "Release"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let size = 12.0 * fit(&s, id); + assert!(size > 24.0, "the text grows on the long line: {size}"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap(); + let released = NodeId(r["texts"][0].as_u64().unwrap()); + let t = text(&s, released); + assert!(!t.dynamic_text, "point type does not keep Dynamic Text"); + assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size); + assert_eq!(fit(&s, released), 1.0); + // Type on a path without Dynamic Text keeps its stored size. + let plain = on_line(&mut s, "Plain"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap(); + let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap())); + assert!(!t.dynamic_text && t.runs[0].style.size == 12.0); +} + /// The id of the first text object in `s`'s document. fn first_text(s: &Session) -> NodeId { let mut found = None; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 6b553b8d1a..0bf3960e86 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -97,6 +97,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 979cd2a436..988cd9a1f0 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1233,6 +1233,36 @@ fn a_font_height_reference_is_written_as_the_size_drawn() { assert!((size - 20.0).abs() < 1e-6, "{size}"); } +/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that +/// the setting itself is not written; without Dynamic Text there is no warning. +#[test] +fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() { + let mut doc = typed(); + assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT)); + let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id; + let mut drawn = 0.0; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.size = 4.0; + t.dynamic_text = true; + drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale; + } + assert!(drawn > 4.0, "{drawn}"); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let imported = crate::import(&out.bytes).unwrap(); + let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap(); + assert!(!back.dynamic_text); + assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size); + // Type written as outlines loses the setting too, and says so. + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].text.push('\u{2192}'); + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); +} + /// Characters with Auto leading are written with their paragraph's Auto Leading share of the /// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index dc5128565d..f71018996c 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -3,8 +3,8 @@ use super::encoding::{ unit, writable, }; use super::{ - Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, - GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, + Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, @@ -666,6 +666,10 @@ impl<'a> Writer<'a> { } let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); let t = &*resolved; + // Written as text or as outlines, the type keeps its drawn sizes but not the setting. + if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) { + self.warn(DYNAMIC_TEXT); + } if !self.outline_text { if writable(self.doc, t) { return self.text_object(t); diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f0790..4eb5e7dc01 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,8 +153,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), TextKind::OnPath { .. } => { items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())); - // Dynamic Text turns on or off, as the Type menu's item does; left out for type in a thread + // Dynamic Text turns on or off, as the Type menu's item does // (<https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html>). + // Leaving it out for type in a thread is VectorCraft's own rule. if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..9a36ac4025 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7317,12 +7317,12 @@ sizes do not change: turning Dynamic Text off writes the sizes the text was draw its look, in one undo step. Point type and area type in the selection are left as they are. Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and -list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step +list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked while the first selected type on a path has it on. `document.inspect` reports `dynamicText` and `fitScale` for type -on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the -editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says -so in a warning. +on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS, legacy +`.ai` and the editing data for other apps write the type at its drawn sizes; legacy `.ai` and the editing data do not +hold the setting, and each says so in a warning. ```json {"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}} @@ -9009,7 +9009,9 @@ VectorCraft addition and changes no parity score. - **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on one straight line along the object's own axis, with the baseline starting where the first character sat on the path (type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip, - brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point + brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point + type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor. + Without `keepOriginal` the type object itself becomes the point type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as `type.convertToPointType` does for area type. - It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..2da0c4a6a6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1158,7 +1158,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). - Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). + Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: reshaping or scaling the path fits the text again; one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. From 7844657096deb4a9f7b1c00f3feb4c112d3ec5ad Mon Sep 17 00:00:00 2001 From: Sven Lafebre <s.lafebre@runbox.com> Date: Wed, 11 Nov 2026 22:09:20 +0100 Subject: [PATCH 013/126] Fix #5111: keep ligatures whole and place line starts in SVG by value - drawn_alike ignores kerning_before, so a space before the "f" of "fi" no longer splits the ligature. - SVG export takes a line's starting space from kerningBefore and the em shaping sets the character in (vectorcraft_text::line_start_space), not from where the glyph is drawn, which can include a shaping offset; the reader layout for justified lines leaves the space out, so those lines start there too. - Type on a path writes the space as the first character's dx; a space after a line break, which <textPath> can't write, is outlined. --- crates/svg/src/export/text.rs | 44 ++++++++++++++++++------- crates/svg/tests/it/text_layout.rs | 30 +++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/shape.rs | 27 ++++++++++++--- crates/text/src/tests/kerning_before.rs | 15 +++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 102 insertions(+), 18 deletions(-) diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 5ff58a97bf..1e5fec21bd 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -258,6 +258,19 @@ impl Writer<'_> { self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); return self.text_outlines(n, t); } + // The space before the first character is written as its `dx`; one before a character + // after a line break, which the `<textPath>` writes as a space, keeps its look as outlines. + let plain = t.plain_text(); + let mut at = 0; + let spaced_break = t.runs.iter().any(|r| { + let start = at; + at += r.text.len(); + r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0) + }); + if spaced_break { + self.warn("type on a path with space before a character after a line break is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `<textPath>` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -559,10 +572,17 @@ impl Writer<'_> { let a = self.attrs(&props); let offset = fmt_num(at * 100.0, 3); let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">"); - for run in &drawn.runs { + let plain = drawn.plain_text(); + let first = drawn.runs.iter().position(|r| !r.text.is_empty()); + for (i, run) in drawn.runs.iter().enumerate() { let mut diff = run_diff(&base, self.char_props(&run.style, space)); diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); + let mut a = self.attrs(&diff); + // The space before the first character (`kerningBefore`) moves it along the path. + let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 }; + if before != 0.0 && before.is_finite() { + a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals))); + } s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " ")))); } s.push_str("</textPath></text>"); @@ -752,14 +772,11 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) } // The space before a line's first character (`kerningBefore`) is in its advance and - // moves it along the line: a reader sets it from where it is drawn, without it. - None if !lay.vertical - && !g.rtl - && g.len > 0 - && run.style.kerning_before != 0.0 - && vectorcraft_text::edit::starts_line(&plain, g.byte) => - { - let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0); + // moves it along the line: a reader sets it that far along, without it. (Shrink + // Text to Fit scales it with the sizes.) + None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => { + let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale; + let before = if before.is_finite() { before } else { 0.0 }; (g.advance - before, g.origin.x + before) } None => (g.advance, g.origin.x), @@ -849,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: // A reader spaces words and letters as the font does. p.justification = vectorcraft_doc::JustificationOptions::default(); } - if !kerning { - for r in &mut set.runs { + for r in &mut set.runs { + if !kerning { r.style.kerning.get_or_insert(0.0); } + // A reader puts no space before a line's first character (`kerningBefore`): the segment + // starts that much further along instead. + r.style.kerning_before = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index e8129a6922..e858f1ba08 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -131,6 +131,36 @@ fn the_space_before_a_lines_first_character_moves_its_tspan() { let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default()); let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap(); assert!((x - 101.5).abs() < 0.01, "{svg}"); + // A justified line, which a reader's own advances place word by word, starts there too. + let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))]; + let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default()); + assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}"); +} + +/// On a path, the space before the first character is its `dx` along the path. One before a +/// character after a line break, which the `<textPath>` can't write, is kept as outlines. +#[test] +fn the_space_before_the_first_character_on_a_path_is_its_dx() { + let doc = |runs: Vec<TextRun>| { + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = runs; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + vectorcraft_svg::export_full(&d, &ExportOptions::default(), None) + }; + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]); + let tags = tspans(&out.svg); + assert!(out.svg.contains("<textPath"), "{}", out.svg); + assert_eq!((attr(tags[0], "dx"), attr(tags[1], "dx")), (Some(3.0), None), "{}", out.svg); + let out = doc(vec![run("HO\n", style(12.0)), run("H", before)]); + assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("line break")), "{:?}", out.warnings); } /// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1e4aeb41d9..d2e3cf1dc8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -49,7 +49,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; -pub use shape::spacing_scale; +pub use shape::{line_start_space, spacing_scale}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index e5d85d50fc..bb258ff225 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -538,6 +538,23 @@ fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0) } +/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets +/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it +/// starts a line by itself, else 0. For writers that place a line's first character themselves. +pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 { + let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase); + space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical)) +} + +/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small +/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size. +fn glyph_size(st: &CharStyle, small: bool) -> f64 { + let full = st.size.max(0.0); + let (pos_scale, _) = st.position.scale_shift(full); + let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; + full * pos_scale * small_scale +} + /// Line segments of a glyph outline in font units, cached for the current shaping pass. type Profile = Vec<(f64, f64, f64, f64)>; type CachedProfile = (Profile, (f64, f64)); @@ -833,8 +850,11 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte: /// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that /// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. +/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space +/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it +/// from its first character, so it doesn't split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { + let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; if a == b { return true; } @@ -1099,9 +1119,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. - let (pos_scale, pos_shift) = st.position.scale_shift(full); - let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; - let size = full * pos_scale * small_scale; + let (_, pos_shift) = st.position.scale_shift(full); + let size = glyph_size(st, small); let k = size / face.upem; let km = full / face.upem; let hs = st.h_scale / 100.0; diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs index c33f3fd97a..f3d9e785a8 100644 --- a/crates/text/src/tests/kerning_before.rs +++ b/crates/text/src/tests/kerning_before.rs @@ -119,3 +119,18 @@ fn in_vertical_type_the_first_character_moves_down_its_column() { } } } + +#[test] +fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() { + // Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one + // glyph, and moves. + let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) }; + let plain = TextObject::point(Point::ZERO, "fit", serif); + let glyphs = |t: &TextObject| layout(db(), t).glyphs.len(); + assert_eq!(glyphs(&plain), 2, "the font joins fi"); + let kerned = spaced(&plain, 0, 250.0); + assert_eq!(glyphs(&kerned), 2, "still joined"); + let moved = moves(&plain, &kerned, &[0, 2]); + assert!(near(&moved, &[25.0, 25.0]), "{moved:?}"); + assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b92606edb..b98b9bb77e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1122,7 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). -- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). +- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn, and on a path writes the space as the first character's `dx`; vertical type moves the character down its column. A space on the first letter of a ligature keeps the ligature whole. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. From 133e2c135695b54396aa102acfe70f7a0c8c22ff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:17:08 -0800 Subject: [PATCH 014/126] MCP: livePaint.info reads id when group is left out The docs said every livePaint key left out is reported as missing. livePaint.info reads `id` when `group` is left out or null, and reports a missing group only when both are left out. --- docs/mcp.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 892541c6a8..6287f5b3a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -436,7 +436,8 @@ change nothing; an omitted or null `id` keeps the default. Commands that need an `path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and `text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of -`node.move` are refused the same way when given; left out, they keep their defaults. A command that leaves out an object selected inside a +`node.move` are refused the same way when given; left out, they keep their defaults. `livePaint.info` reads `id` when +`group` is left out or null, and reports a missing group only when both are left out. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, From bd9fef69b8bd83226a85e07f5ee76124a22bd615 Mon Sep 17 00:00:00 2001 From: Sven Lafebre <s.lafebre@runbox.com> Date: Wed, 11 Nov 2026 22:24:49 +0100 Subject: [PATCH 015/126] Fix #5111: keep coloured harakat attached when the first letter is spaced paint_only ignores kerning_before, as drawn_alike does: a space before a line's first letter, set on the letter alone, no longer splits it from its harakat coloured apart. --- crates/text/src/harakat.rs | 6 +++--- crates/text/src/tests/harakat.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index b781bd8b63..435bbc4ed7 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -38,7 +38,8 @@ pub(crate) fn is_harakat(c: char) -> bool { /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the /// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike` +/// and change no glyph, advance or position. Nor is the space before a line's first character +/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` /// (which also ignores baseline shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. @@ -51,7 +52,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { leading, tracking, kerning, - kerning_before, + kerning_before: _, optical_kerning, baseline_shift, h_scale, @@ -101,7 +102,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && leading == &b.leading && tracking == &b.tracking && kerning == &b.kerning - && kerning_before == &b.kerning_before && optical_kerning == &b.optical_kerning && baseline_shift == &b.baseline_shift && h_scale == &b.h_scale diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index e590f103c7..0aa8bf82aa 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -171,6 +171,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_dash = a.stroke_dash.clone(); b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); + b.kerning_before = a.kerning_before; *a == b } @@ -203,6 +204,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(leading, Some(60.0), false); field!(tracking, 20.0, false); field!(kerning, Some(0.0), false); + field!(kerning_before, 250.0, true); field!(optical_kerning, true, false); field!(baseline_shift, 1.0, false); field!(h_scale, 90.0, false); @@ -312,6 +314,26 @@ fn harakat_ranges_normalise_their_range() { /// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone /// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character; /// everything else stays at the cluster start (conservative). +/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret +/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to +/// left the space is past the letter's right side, so every glyph stays where it was. +#[test] +fn a_space_before_the_first_letter_keeps_its_coloured_harakat() { + let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") else { + return; + }; + const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 + let db = FontDb::global(); + let coloured = text_with_harakat(WORD, &face.family, Some(red())); + let mut spaced = coloured.clone(); + crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0); + let (a, b) = (layout(db, &coloured), layout(db, &spaced)); + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::<Vec<_>>(); + assert_eq!(placed(&a), placed(&b), "the same glyphs where they were"); + assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "the harakat keep their colour"); + assert!(painted(&spaced, &b, &red()) > 0, "harakat painted apart"); +} + #[test] fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and From d9f961b0c15a3e5955a9de213bfffe5ef7873f2f Mon Sep 17 00:00:00 2001 From: Sven Lafebre <s.lafebre@runbox.com> Date: Wed, 11 Nov 2026 22:38:30 +0100 Subject: [PATCH 016/126] Fix #5111: check spaced harakat with the in-memory Arabic font The harakat test now runs on the in-memory Arabic test font in every environment, as well as on an installed Arabic face, and checks that the line is the space longer. --- crates/text/src/tests/harakat.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index 0aa8bf82aa..9bb7700d66 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -316,22 +316,32 @@ fn harakat_ranges_normalise_their_range() { /// everything else stays at the cluster start (conservative). /// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret /// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to -/// left the space is past the letter's right side, so every glyph stays where it was. +/// left the space is past the letter's right side, so every glyph stays where it was and the line +/// is the space longer. Checked with the in-memory Arabic test font (its fatha a `GDEF` mark) in +/// every environment, and with an installed Arabic face when there is one. #[test] fn a_space_before_the_first_letter_keeps_its_coloured_harakat() { - let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") else { - return; - }; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1); + check_spaced_harakat(&db, crate::test_fonts::lam_alef_family(b"rlig")); + if let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") { + check_spaced_harakat(FontDb::global(), &face.family); + } +} + +fn check_spaced_harakat(db: &FontDb, family: &str) { const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 - let db = FontDb::global(); - let coloured = text_with_harakat(WORD, &face.family, Some(red())); + let coloured = text_with_harakat(WORD, family, Some(red())); let mut spaced = coloured.clone(); crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0); let (a, b) = (layout(db, &coloured), layout(db, &spaced)); let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::<Vec<_>>(); - assert_eq!(placed(&a), placed(&b), "the same glyphs where they were"); - assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "the harakat keep their colour"); - assert!(painted(&spaced, &b, &red()) > 0, "harakat painted apart"); + assert_eq!(placed(&a), placed(&b), "{family}: the same glyphs where they were"); + assert!(painted(&spaced, &b, &red()) > 0, "{family}: harakat painted apart"); + assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "{family}: the harakat keep their colour"); + // 250/1000 of the 48 pt em. + let length = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>(); + assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b)); } #[test] From 39683ecdd90db1d3286f7a5d617c888ebc64be66 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:55:47 -0800 Subject: [PATCH 017/126] PDF/.ai export: fit Dynamic Text before resolving font heights The legacy .ai writer resolved cap-height and other font height references to em sizes before fitting, so Dynamic Text stepped and limited the em size instead of the size the Character panel shows, and the type could be written smaller than it is drawn. It now fits first, and outlines come from the type's own layout. The editing data now also warns that Dynamic Text is not written when the type is written as outlines. --- crates/eps/src/ai_editing/tests.rs | 13 ++++++++++ crates/eps/src/ai_editing/writer.rs | 4 +++ crates/eps/src/legacy_ai/tests.rs | 38 +++++++++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 16 ++++++------ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 67ca443a7b..2af2ecd394 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4366,6 +4366,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() { assert!(!texts[0].dynamic_text); let k = texts[0].xf.as_coeffs()[0]; assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn)); + // Type written as outlines (here on an open path whose ends meet) loses the setting too, and + // says so. + let mut doc = Document::new(600.0, 300.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK)); + let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()]; + t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let node = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings); } /// See-through type with the knockout shape flag comes back with the flag and writes no warning. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index afa6412561..dea4ad0c24 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> { return; } self.warn(TYPE); + // The outlines keep the sizes Dynamic Text draws the type at, but not the setting. + if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) { + self.warn(DYNAMIC_TEXT); + } let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 988cd9a1f0..14059d33b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1263,6 +1263,44 @@ fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() { assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); } +/// Dynamic Text fits type whose size measures the cap height at the size the Character panel +/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is +/// written at the em size drawn there, as text and as outlines. +#[test] +fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() { + use vectorcraft_doc::{TextKind, TextObject}; + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(height_font(true).unwrap()); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red())); + let st = &mut t.runs[0].style; + st.font_family = HEIGHT_FAMILY.into(); + st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + let mut line = PathData::new(vec![]); + line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false)); + t.kind = TextKind::OnPath { path: line, start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let lay = vectorcraft_text::layout(db, &t); + assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale); + let em = 1296.0 / 0.7; + let mut glyphs = kurbo::BezPath::new(); + for g in &lay.glyphs { + glyphs.extend(g.outline.iter()); + } + let want = glyphs.bounding_box(); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let back = crate::import(&write(&doc).bytes).unwrap().document; + let size = texts(&back)[0].1.runs[0].style.size; + assert!((size - em).abs() < 1e-3, "{size} vs {em}"); + let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document; + let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap(); + assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}"); +} + /// Characters with Auto leading are written with their paragraph's Auto Leading share of the /// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index f71018996c..062ea3e9d8 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -657,15 +657,11 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type: a text object when the format can hold it as text, else its outlines (type with - /// rotated characters among them, which keeps their rotation). A run whose - /// font size measures a height other than the em box is written at the em size it is drawn - /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]). + /// rotated characters among them, which keeps their rotation). fn text(&mut self, t: &TextObject) { if !invertible(t.xf) { return; } - let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); - let t = &*resolved; // Written as text or as outlines, the type keeps its drawn sizes but not the setting. if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) { self.warn(DYNAMIC_TEXT); @@ -687,14 +683,18 @@ impl<'a> Writer<'a> { /// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame /// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix /// takes text space (y up, the first baseline's start for point type) to art space; the frame - /// and path are in art space, as the specification's examples have them (§15.5). + /// and path are in art space, as the specification's examples have them (§15.5). Type that + /// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at + /// the sizes the Character panel shows; then a run whose font size measures a height other + /// than the em box is written at the em size it is drawn at + /// ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text_object(&mut self, t: &TextObject) { if lost_attributes(t) { self.warn(TYPE_ATTRIBUTES); } - // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in. let fitted = vectorcraft_text::drawn(t); - let t = &*fitted; + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted); + let t = &*resolved; let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let (kind, path, start) = match &t.kind { TextKind::Point => (0, None, 0.0), From 6a7214df4c9695c6105fb81450454132853b12a6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:39:28 -0800 Subject: [PATCH 018/126] MCP: replies hide a library path outside the read roots wherever it appears Under the read roots, every control reply now shows a hidden library's id as loaded/<file name> and its path as the file name in every string and key: status text, dialogs, the command palette query, a script's report, recorded steps and errors. The id and path are replaced at any boundary, so a path between spaces, brackets or quotes, followed by a period or by more path text (<path>.bak), or quoted with escaped backslashes is hidden too. An occurrence that continues into a longer path stays only when the request may read that longer path, so a readable library's id that begins with a hidden one keeps its text. The libraries hidden after the request ran are hidden, and for a request that names one, those hidden before it too. A reply with no path separator in it is not searched. Encoded file data (dataBase64, pngBase64) is sent unchanged. Recorded steps in ui.inspect leave out a path outside the read roots under path, paths, file, files, folder and any param whose name ends in Path, Paths, File, Files or Folder (diffPath, fontFiles, destinationFolder), and an absolute path or file: URL outside the roots under any other param or in any list. The file: scheme is matched in any case, a URL's %XX escapes are decoded before the check, a file: URL inside the roots stays, and a file: URL that names another host is always left out. file_access::is_path_key holds the list of path params, and testkit's is_path_key uses it. --- crates/engine/src/file_access.rs | 7 + crates/testkit/src/strategies.rs | 12 +- crates/ui-egui/src/control.rs | 279 ++++++++++++++++++++----- crates/ui-egui/src/control/tests.rs | 57 +++++ crates/ui-egui/src/tests/automation.rs | 113 ++++++++-- docs/mcp.md | 16 +- 6 files changed, 411 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 27ebc9c40d..fc071ebb98 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, +/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, +/// `destinationFolder`)? +pub fn is_path_key(key: &str) -> bool { + matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) +} + /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 10b0608001..372152870d 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,15 +502,17 @@ pub fn param_keys(doc: &str) -> Vec<String> { out } -/// Parameter-name keys that name a file or folder a command may write: fuzzing these with -/// arbitrary strings could write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write +/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could +/// write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - matches!(k, "path" | "diffPath") || is_folder_key(k) + vectorcraft_engine::file_access::is_path_key(k) } -/// Path keys that name a folder a command creates or writes files into. +/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in +/// `Folder` (`destinationFolder`). pub fn is_folder_key(k: &str) -> bool { - matches!(k, "folder" | "destinationFolder") + k == "folder" || k.ends_with("Folder") } /// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3cbe9f920c..c06951cee5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,23 +308,10 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - let ids = unreadable_library_ids(app); - for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { - if let Some(v) = inspected["ui"].get_mut(*field) { - hide_library_ids(v, &ids); - } - } - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - if let Some(sets) = inspected["ui"].get_mut("action_sets") { - swap_library_params(sets, &pairs); - } + hide_library_ids(&mut inspected, &unreadable_library_ids(app)); inspected } -/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel -/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. -const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; - /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -336,9 +323,9 @@ const PANEL_FIELDS: [&str; 7] = /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of /// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of -/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an -/// action's name) is not read. When another of those ids, or a library loaded in the session, already -/// has that id, the next free one +/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a +/// command palette query, a workspace's or an action's name) is not read. When another of those ids, +/// or a library loaded in the session, already has that id, the next free one /// of `loaded/<file name> (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that /// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { @@ -354,15 +341,16 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { } } collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded + // without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { add_library_id(id, &mut ids); } let mut hidden = Vec::new(); - // The ids of the libraries loaded in the session, so that a hidden one does not take the id of - // one loaded without a path. - let s = &app.session; - let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); - let mut taken: std::collections::BTreeSet<String> = loaded.map(|info| info.id.clone()).collect(); + let mut taken: std::collections::BTreeSet<String> = loaded.iter().map(|id| id.to_string()).collect(); for id in ids { let path = &id["loaded/".len()..]; // Either separator: a path saved on another platform counts as a path here too. @@ -419,11 +407,106 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str } } -/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's -/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. +/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name +/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id +/// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's +/// report and a recorded step. An occurrence followed by a character that continues a path +/// ([`continues_path`]) stays only when the request may read the longer path +/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - swap_library_ids(v, &pairs); + replace_in_value(v, &hidden_text(ids)); +} + +/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the +/// path in it with the file name shown, the path written with either separator too, and with each +/// `\` doubled (a path quoted with `{:?}`). +fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = Vec::new(); + for (id, shown) in ids { + let path = &id["loaded/".len()..]; + let name = shown.strip_prefix("loaded/").unwrap_or(shown); + let back = path.replace('/', "\\"); + for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { + for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } + } + } + } + pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len())); + pairs +} + +/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each +/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the +/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` +/// (`dataBase64`, `pngBase64`), stays as it is. +fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { + if pairs.is_empty() { + return; + } + match v { + Value::String(s) => { + if let Some(replaced) = replace_text(s, pairs) { + *s = replaced; + } + } + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Object(fields) => { + for (key, mut item) in std::mem::take(fields) { + if key.ends_with("Base64") { + fields.insert(key, item); + continue; + } + replace_in_value(&mut item, pairs); + fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + } + } + _ => {} + } +} + +/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes +/// them. `None` when nothing changed. +fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> { + let mut out: Option<String> = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if from.is_empty() || !text.contains(from.as_str()) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from.as_str()) { + let end = at + from.len(); + if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, +/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it. +fn continues_path(rest: &str) -> bool { + rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) +} + +/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, +/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden +/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. +fn readable_continuation(text: &str) -> bool { + let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); + let path = &text[..end]; + readable(path.strip_prefix("loaded/").unwrap_or(path)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -510,10 +593,69 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } -/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in -/// `Folder` (`destinationFolder`, `searchFolder`)? -fn is_path_param(key: &str) -> bool { - matches!(key, "path" | "folder") || key.ends_with("Folder") +/// Is `s` a path the request may not read, written as an absolute path (either separator, with or +/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is +/// never readable. +fn unreadable_absolute(s: &str) -> bool { + match file_url_path(s) { + Some(path) => !path.is_some_and(|p| readable(&p)), + None => { + let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); + absolute && !readable(s) + } + } +} + +/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? +fn readable_value(s: &str) -> bool { + match file_url_path(s) { + Some(path) => path.is_some_and(|p| readable(&p)), + None => readable(s), + } +} + +/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` +/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). +/// `file:///C:/x` names `C:/x`. +fn file_url_path(s: &str) -> Option<Option<String>> { + let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?; + let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) }; + let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + if !rest.starts_with('/') { + return Some(None); + } + let path = match rest.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..], + _ => rest, + }; + Some(Some(percent_decoded(path))) +} + +/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href. +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail + .get(..2) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&out).into_owned() } /// The params of each step of each action of action set `set` (`ui.action_sets`). @@ -524,22 +666,35 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin } /// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the -/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +/// paths the request may not read: the value of a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` +/// and the like), each path of a list under such a param, and under any other param an absolute path +/// or a `file:` URL ([`unreadable_absolute`]). fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { - params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); - if let Some(Value::Array(paths)) = params.get_mut("paths") { - paths.retain(|p| p.as_str().is_none_or(readable)); - } - for v in params.values_mut() { + use vectorcraft_engine::file_access::is_path_key; + params.retain(|key, v| match v.as_str() { + Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + }); + for (key, v) in params.iter_mut() { + if let Value::Array(paths) = v + && is_path_key(key) + { + paths.retain(|p| p.as_str().is_none_or(readable_value)); + } leave_out_unreadable_values(v); } } -/// [`leave_out_unreadable_params`] on each object `v` holds. +/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL +/// the request may not read taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), - Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + Value::Array(items) => { + items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.iter_mut().for_each(leave_out_unreadable_values); + } _ => {} } } @@ -620,21 +775,45 @@ const DOCKING_ACTION: &str = "action"; /// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id -/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. +/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each +/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, +/// errors included: the libraries hidden after it ran, and before it for a request that names one. +/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { - let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); - let mut params = req.params.clone(); - if shown.is_empty() || !swap_library_params(&mut params, &shown) { + if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - match dispatch(app, ctx, &named) { - Outcome::Done(mut reply) => { - hide_library_ids(&mut reply, &ids); - Outcome::Done(reply) + let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + }; + let Outcome::Done(mut reply) = outcome else { return outcome }; + if !holds_a_separator(&reply) { + return Outcome::Done(reply); + } + let mut ids = unreadable_library_ids(app); + for pair in before { + if !ids.iter().any(|(id, _)| *id == pair.0) { + ids.push(pair); } - outcome => outcome, + } + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) +} + +/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +fn holds_a_separator(v: &Value) -> bool { + let has = |s: &str| s.contains(['/', '\\']); + match v { + Value::String(s) => has(s), + Value::Array(items) => items.iter().any(holds_a_separator), + Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)), + _ => false, } } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0a9c042a1e..34b96b81e0 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,6 +12,63 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in +/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`. +#[test] +fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { + let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); + let id = format!("loaded/{path}"); + let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); + let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); +} + +/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; + let data = "QUJDassets/iconREVG"; + let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); + let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); + assert_eq!(reply["result"]["status"], "from icon", "{reply}"); +} + +/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a +/// relative name and a path inside the root are not. +#[test] +fn an_absolute_path_outside_the_read_root_is_unreadable() { + let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); + std::fs::create_dir_all(base.join("in")).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(super::unreadable_absolute("/outside/root/x")); + assert!(super::unreadable_absolute("file:///outside/root/x")); + assert!(super::unreadable_absolute("FILE:///outside/root/x")); + assert!(super::unreadable_absolute("file://other-host/share/x")); + let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) }; + assert!(!super::unreadable_absolute(&url), "{url}"); + assert!(super::readable_value(&url), "{url}"); + let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1); + assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}"); + assert!(super::unreadable_absolute("file:///outside%2Froot/x")); + assert!(!super::unreadable_absolute("")); + assert!(!super::unreadable_absolute("keep")); + assert!(!super::unreadable_absolute(&inside)); + }); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index f3aee6ca73..2db9feaec5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -439,22 +439,106 @@ fn ui_inspect_checks_a_folder_preference_as_written() { let _ = std::fs::remove_dir_all(base); } -/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: -/// status text that is that id stays as written. +/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the +/// command palette query and a script's report show the id and file name shown, and the path appears +/// nowhere in the reply. #[test] -fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { let (mut app, tx, base) = confined("inspect-status-id"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); app.ui.color_guide_limit = theirs.clone(); - app.ui.status = theirs.clone(); + app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); + app.ui.palette_query = format!("\"{theirs}\""); + let source = format!("vc.log({})", json!(format!("read {path}."))); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } let r = request(&mut app, &tx, "ui.inspect", json!({})); assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); - assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that +/// library's id wherever the reply holds it, also between spaces and brackets and where a character +/// that continues a path follows it. +#[test] +fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { + let (mut app, tx, base) = confined("named-hidden-reply"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); + assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, +/// and its path appears nowhere in the reply. +#[test] +fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { + let (mut app, tx, base) = confined("named-hidden-error"); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path +/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside +/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. +#[test] +fn ui_inspect_leaves_out_recorded_paths_under_any_key() { + let (mut app, tx, base) = confined("inspect-action-keys"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; + let (url, url_in) = (to_url(&b), to_url(&a)); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["document.compare", {"diffPath": b, "target": a}], + ["file.open", {"file": b}], + ["type.fonts", {"fontFiles": [a, b, url_in]}], + ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], + ["x.z", {"list": [b, "keep"]}], + ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], + ["x.v", {"file": url_in}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["document.compare", {"target": a}], + ["file.open", {}], + ["type.fonts", {"fontFiles": [a, url_in]}], + ["x.y", {"other": "keep"}], + ["x.z", {"list": ["keep"]}], + ["x.w", {"other": "keep"}], + ["x.v", {"file": url_in}], + ]); + assert_eq!(steps, &want, "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); let _ = std::fs::remove_dir_all(base); } /// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action -/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. #[test] fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { let (mut app, tx, base) = confined("inspect-action-steps"); @@ -470,7 +554,7 @@ fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { app.ui.action_sets = vec![set]; let r = request(&mut app, &tx, "ui.inspect", json!({})); let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; - assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!(action["name"], "loaded/b.svg", "{r}"); assert_eq!( action["steps"], json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), @@ -639,7 +723,7 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { /// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable /// library's id that begins with it stays, and a script's report line and a command palette query -/// that hold it stay as written. +/// that end with it show the id shown. #[test] fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let (mut app, tx, base) = confined("inspect-whole-id"); @@ -648,8 +732,7 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { assert!(readable.starts_with(&hidden)); let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; - let query = format!("library:swatches:{hidden}"); - app.ui.palette_query = query.clone(); + app.ui.palette_query = format!("library:swatches:{hidden}"); let text = format!("found {hidden}"); let source = format!("vc.log({})", json!(text)); app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); @@ -661,8 +744,12 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let r = request(&mut app, &tx, "ui.inspect", json!({})); let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); - assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); - assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); + // A request that names the hidden library by the id shown keeps the readable id too. + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); let _ = std::fs::remove_dir_all(base); } diff --git a/docs/mcp.md b/docs/mcp.md index b8265bdfd2..3deca5409a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,8 +129,11 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, - a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, + `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, + `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an + absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names + another host is always left out); and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -144,9 +147,12 @@ the suite) keep the agent to the folders you give it: their order, and the app's own state is not changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` - reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: - a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. + reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path + appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its + path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one + keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one + stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From 7f641b9c10d3bfc7de9a5d866b541b4f256d8256 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 14:00:31 -0800 Subject: [PATCH 019/126] Help: a stalled interface writes a hang report, and Copy Troubleshooting Info copies it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the interface thread stays in one part of a frame for 3 s, a watching thread writes a hang report to the log and to logs/hang-report-<process id>.txt. The report gives the part of the frame (input hook, logic, interface, or the renderer after a frame in which any window had input), a system clipboard call in progress, the last 8 different command ids, the last 16 frame times, the platform, window system, desktop and session type, Flatpak, graphics adapter, the present mode and frame latency the app asks for, the running time and the number of open documents. It holds no document contents, file paths or command parameters. The interface thread marks its stages with a few atomic stores a frame, and the watching thread reads those marks without waiting on it. The process keeps the report file locked until the interface answers again, then removes it. A file nobody holds at a later launch was left by a session that was closed while it did not answer: the launch writes the report to the log, moves the file to logs/hang-report.1.txt and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info puts the platform facts, the recent commands and frame times and both sessions' reports on the clipboard for a bug report. An in-line file dialog, an open menu of the macOS menu bar, and the time after a frame that minimizes or hides the window or starts moving or resizing it are not timed. The menu label and the two status messages have rows in every catalog. --- apps/vectorcraft/src/app.rs | 34 +- apps/vectorcraft/src/hang_watch.rs | 445 ++++++++++++++++++ apps/vectorcraft/src/hang_watch/tests.rs | 270 +++++++++++ apps/vectorcraft/src/host_services.rs | 5 +- apps/vectorcraft/src/main.rs | 42 ++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/commands.rs | 1 + crates/ui-egui/src/hang_beacon.rs | 557 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/services.rs | 4 + crates/ui-egui/src/troubleshooting.rs | 72 +++ docs/development.md | 2 + docs/target-app-parity.md | 1 + 43 files changed, 1580 insertions(+), 2 deletions(-) create mode 100644 apps/vectorcraft/src/hang_watch.rs create mode 100644 apps/vectorcraft/src/hang_watch/tests.rs create mode 100644 crates/ui-egui/src/hang_beacon.rs create mode 100644 crates/ui-egui/src/troubleshooting.rs diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index fa0f0dca29..bfd8ce1c18 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -9,6 +9,7 @@ use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; use vectorcraft_ui_egui::dialogs::safe_mode; +use vectorcraft_ui_egui::hang_beacon::{self, BEACON, FrameTime, Stage}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -34,12 +35,16 @@ impl eframe::App for App { // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + BEACON.enter(Stage::Logic); file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); + BEACON.enter(Stage::Idle); } /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + BEACON.enter(Stage::Input); + self.frame_start = Some(std::time::Instant::now()); // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). if let Some(window_frame) = self.window_frame.as_mut() { @@ -60,9 +65,10 @@ impl eframe::App for App { // that no mouse button is down. #[cfg(target_os = "macos")] self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); + BEACON.enter(Stage::Idle); } - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + BEACON.enter(Stage::Interface); #[cfg(target_os = "macos")] { let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; @@ -89,8 +95,13 @@ impl eframe::App for App { if !self.app.file_dialog_open() && self.app.take_ime_discard() { discard_marked_text(); } + self.end_frame(ui.ctx(), frame); } fn on_exit(&mut self) { + // The window is closing: saving and teardown are not timed for the hang report, and a report + // of a stall the interface came back from is removed. + let log_dir = super::prefs::log_dir().filter(|_| super::prefs::prefs_enabled()); + super::hang_watch::closing(log_dir.as_deref(), &super::hang_watch::CLOSING, &BEACON); file_access::unconfined(|| save_prefs(&self.app)); // A ready update installs once the process has exited. The macOS exit below skips // dropping the app, so the helper is started here. @@ -102,6 +113,27 @@ impl eframe::App for App { } impl App { + /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a + /// focused, shown window whose windows had input waits for the renderer and the next frame, + /// which the input asks for at once; any other frame leaves the interface thread idle + /// (`hang_watch`). + fn end_frame(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + // A pass egui discards and runs again is not the frame's end: the pass after it is. + if ctx.will_discard() { + return; + } + if let Some(start) = self.frame_start.take() { + let work_ms = start.elapsed().as_secs_f32() * 1000.0; + let previous_total_ms = frame.info().cpu_usage.map(|s| s * 1000.0); + BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len()); + } + // Hide on macOS hides the window at once, before the window reports losing focus. + let shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) + && self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false)); + let input = BEACON.take_input(); + BEACON.enter(ctx.viewport(|vp| hang_beacon::after_frame(input, shown, &vp.commands))); + } + fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] if self.winit_window.as_ref().is_some_and(|w| !w.has_focus() || w.is_visible() == Some(false) || w.is_minimized() == Some(true)) { diff --git a/apps/vectorcraft/src/hang_watch.rs b/apps/vectorcraft/src/hang_watch.rs new file mode 100644 index 0000000000..e5adb77dfa --- /dev/null +++ b/apps/vectorcraft/src/hang_watch.rs @@ -0,0 +1,445 @@ +//! The hang report. A thread reads the interface thread's marks +//! ([`vectorcraft_ui_egui::hang_beacon`]) every [`POLL`]. When the interface thread has made no +//! mark for [`STALL`] while inside a stage of its frame, the thread writes what the app knows to the +//! log (`logs/vectorcraft.log`) and to `logs/hang-report-<process id>.txt`: the stage, a clipboard +//! call in progress, the last commands, the last frames' times, the platform, the window system, the +//! graphics processor and the present mode the app asked for, and how long the session has run with +//! how many documents open. The process holds a lock on the file until the interface thread answers +//! again; then the log says after how long and the file is removed. A file nobody holds at a later +//! launch was left by a session that was closed while it did not answer, and a locked one belongs to +//! another instance that is stalled now and is left alone. That launch writes the report to the log, +//! moves the newest abandoned file to `hang-report.1.txt` and removes the others, says so in the +//! status bar, and Help › Copy Troubleshooting Info copies the reports. Where the file system has no +//! file locks, the file is written and taken without one. +//! +//! No native stack is recorded: the marks are what the report has. The report holds command ids and +//! counts, never document contents or file paths. [`STALL`] is VectorCraft's own value. +//! +//! A stall in the renderer is timed only after a frame that had input in any window: input asks for +//! the next frame at once, so a frame that does not start then is late. After a frame without +//! input, the window waiting for input looks the same as one stuck drawing, so that time is not +//! timed. Nor is the time after a frame that minimizes or hides the main window or hands it to the +//! system to move or resize. A window the system stops redrawing while it is hidden can still look +//! like a stall after a frame with input; the report's stage says so. A system file dialog shown in +//! line (on Windows and Linux) and an open menu of the macOS menu bar wait for the user and are not +//! timed. + +use std::fs::File; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant, SystemTime}; + +#[cfg(target_os = "macos")] +use vectorcraft_ui_egui::hang_beacon::Activity; +use vectorcraft_ui_egui::hang_beacon::{BEACON, Beacon, FrameTime, Sample, Stage}; + +use super::logging::timestamp; + +/// How long the interface thread may stay in one stage before the report is written. +pub const STALL: Duration = Duration::from_secs(3); +/// How often the watching thread reads the marks. +pub const POLL: Duration = Duration::from_millis(500); +/// The report of a stall that has not ended, in the log folder: this, the process id, `.txt`. +pub const REPORT_PREFIX: &str = "hang-report-"; +/// At most this many report files are taken when a launch looks for abandoned ones; older abandoned +/// ones are removed. +const MAX_REPORT_FILES: usize = 16; +/// At most this many entries of the log folder are looked at for report files. +const MAX_SCANNED_ENTRIES: usize = 1024; +/// The report the last session left, moved here at launch. +pub const PREVIOUS_FILE: &str = "hang-report.1.txt"; +/// A report file larger than this is not read. +pub const MAX_REPORT_BYTES: u64 = 64 * 1024; +/// Environment values in the report are cut to this many characters. +const MAX_VALUE_CHARS: usize = 64; + +/// What [`Watch::check`] found. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Event { + /// The interface thread has been in the sample's stage, without a mark, for this long. + Stalled(Duration), + /// The interface thread made a mark again after a [`Event::Stalled`], about this long after + /// the stall began. + Answered(Duration), +} + +/// Decides from successive samples when the interface thread has stalled and when it answers +/// again. Time comes from the caller, so tests drive it without waiting. +#[derive(Debug)] +pub struct Watch { + stall: Duration, + /// The step seen in a busy stage and when it was first seen. + seen: Option<(u64, Duration)>, + reported: bool, +} + +impl Watch { + pub fn new(stall: Duration) -> Watch { + Watch { stall, seen: None, reported: false } + } + + /// The marks read at `now` (any monotonic clock that started before the first call). + pub fn check(&mut self, now: Duration, sample: Sample) -> Option<Event> { + let same = self.seen.is_some_and(|(step, _)| step == sample.step); + if same && sample.busy() { + let since = self.seen.map_or(now, |(_, since)| since); + let stalled = now.saturating_sub(since); + if !self.reported && stalled >= self.stall { + self.reported = true; + return Some(Event::Stalled(stalled)); + } + return None; + } + let answered = match (self.reported, self.seen) { + (true, Some((_, since))) => Some(Event::Answered(now.saturating_sub(since))), + _ => None, + }; + self.reported = false; + self.seen = sample.busy().then_some((sample.step, now)); + answered + } +} + +/// What the report says about the platform. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Facts { + /// The window system the window opened on: Wayland, X11, AppKit, Win32, other or unknown. + pub window_system: String, + /// `XDG_CURRENT_DESKTOP`. + pub desktop: Option<String>, + /// `XDG_SESSION_TYPE`. + pub session_type: Option<String>, + /// Running in a Flatpak sandbox. + pub flatpak: bool, + /// The graphics processor the window draws with. + pub graphics: Option<String>, + /// The present mode and frame queue the app asked the window surface for. + pub surface: String, +} + +impl Facts { + /// The facts from the environment (`env`, `flatpak_info`: whether `/.flatpak-info` exists) and + /// what the app knows. + pub fn new(env: impl Fn(&str) -> Option<String>, flatpak_info: bool, window_system: &str, graphics: Option<String>, surface: String) -> Facts { + let value = |name: &str| env(name).map(|v| clean(&v)).filter(|v| !v.is_empty()); + Facts { + window_system: window_system.to_string(), + desktop: value("XDG_CURRENT_DESKTOP"), + session_type: value("XDG_SESSION_TYPE"), + flatpak: flatpak_info || env("FLATPAK_ID").is_some_and(|v| !v.is_empty()), + graphics, + surface, + } + } + + /// The facts as report lines. + pub fn lines(&self) -> String { + let or_unknown = |v: &Option<String>| v.clone().unwrap_or_else(|| "unknown".to_string()); + format!( + "VectorCraft {} on {} {}\nWindow system: {}; desktop: {}; session type: {}; Flatpak: {}\nGraphics: {}; {}\n", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + self.window_system, + or_unknown(&self.desktop), + or_unknown(&self.session_type), + if self.flatpak { "yes" } else { "no" }, + or_unknown(&self.graphics), + self.surface, + ) + } +} + +/// `v` with control characters removed, cut to [`MAX_VALUE_CHARS`] characters. +fn clean(v: &str) -> String { + v.chars().filter(|c| !c.is_control()).take(MAX_VALUE_CHARS).collect::<String>().trim().to_string() +} + +/// The marks other than the stage: the last commands, the last frames and the session. +fn activity_lines(beacon: &Beacon, uptime: Duration) -> String { + let commands = match beacon.recent_commands() { + Some(c) if c.is_empty() => "none".to_string(), + Some(c) => c.join(", "), + None => "unavailable (being written)".to_string(), + }; + let (work, total) = match beacon.recent_frames() { + Some(frames) => { + let list = |f: &dyn Fn(&FrameTime) -> Option<f32>| { + let times: Vec<String> = frames.iter().filter_map(f).map(|ms| format!("{ms:.1}")).collect(); + if times.is_empty() { "none".to_string() } else { times.join(", ") } + }; + (list(&|t| Some(t.work_ms)), list(&|t| t.previous_total_ms)) + } + None => ("unavailable (being written)".to_string(), "unavailable (being written)".to_string()), + }; + format!( + "Recent commands, oldest first: {commands}\nApp work per frame (ms), oldest first: {work}\nWork and drawing per frame, without waiting for the display (ms), oldest first: {total}\nSession: running for {} s, {} documents open\n", + uptime.as_secs(), + beacon.documents(), + ) +} + +/// The report of a stall that has lasted `stalled` in `sample`'s stage, written at `at`. +pub fn report(at: SystemTime, stalled: Duration, sample: Sample, beacon: &Beacon, uptime: Duration, facts: &Facts) -> String { + let waiting = sample.activity.describe().map(|a| format!("Waiting on: {a}\n")).unwrap_or_default(); + format!( + "VectorCraft hang report, {}\nThe interface thread has not moved on for {:.1} s.\nStage: {}\n{waiting}{}{}No native stack is recorded: the stage and the activity are what the app marks.\n", + timestamp(at), + stalled.as_secs_f64(), + sample.stage.describe(), + activity_lines(beacon, uptime), + facts.lines(), + ) +} + +/// What the watching thread and Help › Copy Troubleshooting Info share. +pub struct Shared { + pub facts: Facts, + pub started: Instant, + /// This session's last report. + pub last: Mutex<Option<String>>, + /// The report the last session left ([`take_previous`]). + pub previous: Option<String>, +} + +/// The text Help › Copy Troubleshooting Info copies. +pub fn troubleshooting(shared: &Shared, beacon: &Beacon, at: SystemTime) -> String { + let last = shared.last.lock().unwrap_or_else(PoisonError::into_inner).clone(); + format!( + "VectorCraft troubleshooting info, {}\n{}{}\nThis session's last hang report:\n{}\nThe hang report the last session left (it was closed while it did not answer):\n{}\n", + timestamp(at), + shared.facts.lines(), + activity_lines(beacon, shared.started.elapsed()), + last.as_deref().unwrap_or("none\n"), + shared.previous.as_deref().unwrap_or("none\n"), + ) +} + +/// The report file of this process in `dir`. +fn report_path(dir: &Path) -> PathBuf { + dir.join(format!("{REPORT_PREFIX}{}.txt", std::process::id())) +} + +/// A report file this process wrote and holds locked until the interface answers again. +#[derive(Debug)] +pub struct Held { + path: PathBuf, + _file: File, +} + +/// Write `text` to this process's report file in `dir` and lock it. Where the file system has no +/// file locks, the file is written unlocked and the log says so. +fn write_report(dir: &Path, text: &str) -> std::io::Result<Held> { + std::fs::create_dir_all(dir)?; + let path = report_path(dir); + let mut file = File::create(&path)?; + if let Err(e) = file.lock() { + log::warn!("the hang report file is written without a lock: {e}"); + } + file.write_all(text.as_bytes())?; + Ok(Held { path, _file: file }) +} + +/// Whether a report file whose `try_lock` gave `locked` was abandoned: nobody holds it. A file +/// system without file locks answers [`std::io::ErrorKind::Unsupported`], and its files count as +/// abandoned; any other error leaves the file alone. +fn abandoned(locked: Result<(), std::fs::TryLockError>) -> bool { + match locked { + Ok(()) => true, + Err(std::fs::TryLockError::Error(e)) => e.kind() == std::io::ErrorKind::Unsupported, + Err(std::fs::TryLockError::WouldBlock) => false, + } +} + +/// The report file at `path`, opened and locked, with its length, when it was abandoned +/// ([`abandoned`]) and is not empty (an empty one may be being written). +fn open_abandoned(path: &Path) -> Option<(File, u64)> { + let file = File::options().read(true).write(true).open(path).ok()?; + if !abandoned(file.try_lock()) { + return None; + } + let len = file.metadata().map_or(0, |m| m.len()); + (len > 0).then_some((file, len)) +} + +/// The reports that sessions closed while they did not answer left in `dir`, oldest first; `None` +/// when there are none. The newest file is moved to [`PREVIOUS_FILE`] and the others are removed +/// once read. Of more than [`MAX_REPORT_FILES`], the newest are taken and the older ones removed +/// unread; the first [`MAX_SCANNED_ENTRIES`] entries of `dir` are looked at. A report file another +/// process holds locked, or one still empty, is left alone. A file over [`MAX_REPORT_BYTES`] is +/// taken without being read. A file that can't be moved is removed, so no later launch offers it +/// again: the caller writes the reports to the log. Each file is opened once. +pub fn take_previous(dir: &Path) -> Option<String> { + let mut found: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir) + .ok()? + .take(MAX_SCANNED_ENTRIES) + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix(REPORT_PREFIX)?.strip_suffix(".txt")) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|e| (e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH), e.path())) + .collect(); + found.sort(); + // Newest first: only abandoned reports count toward the limit. + let mut texts = Vec::new(); + for (_, path) in found.into_iter().rev() { + let Some((file, len)) = open_abandoned(&path) else { continue }; + if texts.len() == MAX_REPORT_FILES { + drop(file); + remove_quietly(&path); + continue; + } + let mut bytes = Vec::new(); + let text = if len <= MAX_REPORT_BYTES && (&file).take(MAX_REPORT_BYTES).read_to_end(&mut bytes).is_ok() { + String::from_utf8_lossy(&bytes).into_owned() + } else { + format!("The report was larger than {MAX_REPORT_BYTES} bytes and was not read.\n") + }; + // Closed (and unlocked) before it is moved: Windows moves no open file. + drop(file); + if !texts.is_empty() { + remove_quietly(&path); + } else if let Err(e) = std::fs::rename(&path, dir.join(PREVIOUS_FILE)) { + log::warn!("couldn't move the hang report file to {PREVIOUS_FILE}: {e}"); + remove_quietly(&path); + } + texts.push(text); + } + texts.reverse(); + (!texts.is_empty()).then(|| texts.join("\n")) +} + +/// Remove the report file at `path`; a failure goes to the log. +fn remove_quietly(path: &Path) { + if let Err(e) = std::fs::remove_file(path) { + log::warn!("couldn't remove the hang report file: {e}"); + } +} + +/// Set when the app closes normally ([`closing`]): the watching thread writes no report file after it. +pub static CLOSING: AtomicBool = AtomicBool::new(false); + +/// Remove `held`'s file, closing it first. → the error message; a file already gone is none. +fn remove(held: Held) -> Option<String> { + let Held { path, _file: file } = held; + drop(file); + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Some(e.to_string()), + _ => None, + } +} + +/// Act on what [`Watch::check`] found: write (and hold) or remove this process's report file in +/// `dir`, then log it. The file comes first: logging can wait on what the interface thread waits on. +#[allow(clippy::too_many_arguments)] +pub fn record( + event: Event, + sample: Sample, + beacon: &Beacon, + shared: &Shared, + dir: Option<&Path>, + held: &mut Option<Held>, + closing: &AtomicBool, + at: SystemTime, +) { + match event { + Event::Stalled(stalled) => { + let text = report(at, stalled, sample, beacon, shared.started.elapsed(), &shared.facts); + let mut failed = None; + if let Some(dir) = dir.filter(|_| !closing.load(Ordering::Acquire)) { + match write_report(dir, &text) { + Ok(file) => *held = Some(file), + Err(e) => failed = Some(e.to_string()), + } + // The app began to close while the file was written: it is not a report to leave. + if closing.load(Ordering::Acquire) { + failed = held.take().and_then(remove).or(failed); + } + } + *shared.last.lock().unwrap_or_else(PoisonError::into_inner) = Some(text.clone()); + log::warn!("the interface stopped answering:\n{}", text.trim_end()); + if let Some(e) = failed { + log::warn!("couldn't write or remove the hang report file: {e}"); + } + } + Event::Answered(after) => { + let failed = held.take().and_then(remove); + log::warn!("the interface answered again about {:.1} s after it stopped", after.as_secs_f64()); + if let Some(e) = failed { + log::warn!("couldn't remove the hang report file: {e}"); + } + } + } +} + +/// The app is closing normally: mark the interface thread idle on `beacon` (saving and teardown are +/// not timed), remove this process's report file in `dir` and have the watching thread write no +/// more. It does not wait for the watching thread. The file can be removed while that thread holds +/// it open and locked: the standard library opens files with `FILE_SHARE_DELETE` on Windows, and a +/// byte-range lock does not prevent deletion. Where Windows deletes a file only once its last +/// handle closes, the name goes when the process ends. +pub fn closing(dir: Option<&Path>, closing: &AtomicBool, beacon: &Beacon) { + beacon.enter(Stage::Idle); + closing.store(true, Ordering::Release); + if let Some(dir) = dir { + match std::fs::remove_file(report_path(dir)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("couldn't remove the hang report file: {e}"), + _ => {} + } + } +} + +/// The window system `window` opened on, for [`Facts::window_system`]. +pub fn window_system(window: Option<&winit::window::Window>) -> &'static str { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + match window.and_then(|w| w.window_handle().ok()).map(|h| h.as_raw()) { + Some(RawWindowHandle::Wayland(_)) => "Wayland", + Some(RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) => "X11", + Some(RawWindowHandle::AppKit(_)) => "AppKit", + Some(RawWindowHandle::Win32(_)) => "Win32", + Some(_) => "other", + None => "unknown", + } +} + +/// The present mode and frame queue the app asked the window surface for, for [`Facts::surface`]; +/// `None` when the window has no wgpu renderer. +pub fn surface(config: Option<(String, Option<u32>)>) -> String { + match config { + Some((mode, Some(frames))) => format!("requested present mode {mode}, frame latency {frames}"), + Some((mode, None)) => format!("requested present mode {mode}"), + None => "no wgpu renderer".to_string(), + } +} + +/// Start the watching thread; the report file goes in `dir` (none: the log only). +pub fn start(dir: Option<PathBuf>, shared: Arc<Shared>) { + let spawned = std::thread::Builder::new().name("hang-watch".into()).spawn(move || { + let mut watch = Watch::new(STALL); + let mut held = None; + loop { + std::thread::sleep(POLL); + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] + let mut sample = BEACON.sample(); + // An open menu of the menu bar waits for the user. + #[cfg(target_os = "macos")] + if super::mac_menu::is_tracking() { + sample.activity = Activity::Menu; + } + if let Some(event) = watch.check(shared.started.elapsed(), sample) { + record(event, sample, &BEACON, &shared, dir.as_deref(), &mut held, &CLOSING, SystemTime::now()); + } + } + }); + if let Err(e) = spawned { + log::warn!("no hang report this session: the watching thread didn't start: {e}"); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/vectorcraft/src/hang_watch/tests.rs b/apps/vectorcraft/src/hang_watch/tests.rs new file mode 100644 index 0000000000..09cf74e403 --- /dev/null +++ b/apps/vectorcraft/src/hang_watch/tests.rs @@ -0,0 +1,270 @@ +use super::*; +use std::time::UNIX_EPOCH; +use vectorcraft_ui_egui::hang_beacon::{Activity, Stage}; + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn sample(step: u64, stage: Stage) -> Sample { + Sample { step, stage, activity: Activity::None } +} + +fn facts() -> Facts { + let env = |name: &str| match name { + "XDG_CURRENT_DESKTOP" => Some("KDE".to_string()), + "XDG_SESSION_TYPE" => Some("wayland\u{7}".to_string()), + _ => None, + }; + Facts::new(env, true, "Wayland", Some("Test GPU (Vulkan, DiscreteGpu)".to_string()), surface(Some(("AutoVsync".to_string(), Some(1))))) +} + +fn shared(previous: Option<String>) -> Shared { + Shared { facts: facts(), started: Instant::now(), last: Mutex::new(None), previous } +} + +#[test] +fn a_stage_held_for_the_stall_time_is_reported_once_and_its_end_after_it() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(2_999), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(3_000), sample(5, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + assert_eq!(w.check(ms(9_000), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(9_500), sample(6, Stage::Idle)), Some(Event::Answered(ms(9_500)))); + assert_eq!(w.check(ms(20_000), sample(6, Stage::Idle)), None); +} + +#[test] +fn frames_that_keep_moving_and_an_idle_window_are_never_reported() { + let mut w = Watch::new(STALL); + // A long animation: the step changes at every read. + for n in 0..100 { + assert_eq!(w.check(ms(n * 500), sample(n, Stage::Drawing)), None); + } + // A window waiting for input for a minute. + for n in 0..120 { + assert_eq!(w.check(ms(50_000 + n * 500), sample(1_000, Stage::Idle)), None); + } +} + +#[test] +fn a_file_dialog_shown_in_line_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let dialog = Sample { step: 7, stage: Stage::Interface, activity: Activity::Dialog }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), dialog), None); + } + // Back from the dialog, the command goes on and is timed again. + assert_eq!(w.check(ms(30_000), sample(8, Stage::Interface)), None); + assert_eq!(w.check(ms(33_000), sample(8, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); +} + +#[test] +fn an_open_menu_bar_menu_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let menu = Sample { step: 3, stage: Stage::Drawing, activity: Activity::Menu }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), menu), None); + } +} + +#[test] +fn a_new_busy_step_starts_the_time_again() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(1, Stage::Logic)), None); + assert_eq!(w.check(ms(2_500), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_000), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_500), sample(2, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + // A mark inside the stall (a clipboard read ending) is an answer. + assert_eq!(w.check(ms(6_000), sample(3, Stage::Interface)), Some(Event::Answered(ms(3_500)))); +} + +#[test] +fn the_report_names_the_stage_the_activity_the_commands_and_the_platform() { + let beacon = Beacon::new(); + beacon.command("edit.copy"); + beacon.command("edit.paste"); + beacon.frame(FrameTime { work_ms: 1.24, previous_total_ms: Some(3.5) }, 2); + let s = Sample { step: 9, stage: Stage::Interface, activity: Activity::ClipboardRead }; + let text = report(UNIX_EPOCH + Duration::from_secs(1_794_385_145), ms(3_240), s, &beacon, Duration::from_secs(754), &facts()); + for want in [ + "VectorCraft hang report, 2026-11-11T08:19:05.000Z", + "has not moved on for 3.2 s", + "Stage: running the interface", + "Waiting on: reading the system clipboard", + "Recent commands, oldest first: edit.copy, edit.paste", + "App work per frame (ms), oldest first: 1.2", + "without waiting for the display (ms), oldest first: 3.5", + "Session: running for 754 s, 2 documents open", + "Window system: Wayland; desktop: KDE; session type: wayland; Flatpak: yes", + "Graphics: Test GPU (Vulkan, DiscreteGpu); requested present mode AutoVsync, frame latency 1", + ] { + assert!(text.contains(want), "{want:?} missing from:\n{text}"); + } +} + +#[test] +fn environment_values_lose_control_characters_and_are_cut_short() { + let long = "x".repeat(500); + let env = |name: &str| (name == "XDG_CURRENT_DESKTOP").then(|| format!("A\nB{long}")); + let f = Facts::new(env, false, "X11", None, String::new()); + assert_eq!(f.desktop.as_deref().map(str::len), Some(MAX_VALUE_CHARS)); + assert!(f.desktop.as_deref().is_some_and(|d| d.starts_with("ABx"))); + assert!(!f.flatpak); + assert!(f.lines().contains("Graphics: unknown")); +} + +#[test] +fn a_stall_writes_the_report_file_and_its_end_removes_it() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let s = sample(4, Stage::Drawing); + let (mut held, open) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + let path = report_path(dir.path()); + // Windows refuses reads through another handle while the file is locked: the length is read. + let last = shared.last.lock().unwrap().clone().unwrap_or_default(); + assert!(last.contains("Stage: after a frame with input went to the renderer")); + assert_eq!(std::fs::metadata(&path).unwrap().len(), last.len() as u64); + // While the stall lasts, a launch of another instance leaves the file alone. + assert_eq!(take_previous(dir.path()), None); + assert!(path.exists()); + record(Event::Answered(ms(4_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + assert!(!path.exists()); + // This session's report stays for Copy Troubleshooting Info. + assert!(shared.last.lock().unwrap().is_some()); +} + +#[test] +fn closing_normally_removes_the_report_and_writes_no_more() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let (mut held, flag) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), sample(4, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + let path = report_path(dir.path()); + assert!(path.exists()); + // The interface answered after a frame with input, and the app quit before the watching thread + // looked again: saving and teardown are not timed. + beacon.enter(Stage::Drawing); + closing(Some(dir.path()), &flag, &beacon); + assert_eq!(beacon.sample().stage, Stage::Idle); + assert!(!path.exists()); + assert_eq!(take_previous(dir.path()), None); + // The watching thread then sees the answer. + record(Event::Answered(ms(3_500)), sample(5, Stage::Idle), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(held.is_none()); + // A stall the watching thread finds while the app closes goes to the log only. + record(Event::Stalled(ms(3_000)), sample(9, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(!path.exists()); + assert!(held.is_none()); +} + +#[test] +fn the_next_launch_takes_a_left_report_once_and_keeps_a_copy() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(take_previous(dir.path()), None); + let left = dir.path().join(format!("{REPORT_PREFIX}4242.txt")); + std::fs::write(&left, "VectorCraft hang report, then\n").unwrap(); + // Other files in the log folder are not reports. + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}notes.txt")), "x").unwrap(); + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}17.txt")), "").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("VectorCraft hang report, then\n")); + assert!(!left.exists()); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "VectorCraft hang report, then\n"); + assert_eq!(take_previous(dir.path()), None); + // An empty file may be one being written: it stays. + assert!(dir.path().join(format!("{REPORT_PREFIX}17.txt")).exists()); +} + +#[test] +fn of_several_left_reports_the_newest_is_kept_and_all_are_offered_oldest_first() { + let dir = tempfile::tempdir().unwrap(); + for (pid, text, secs) in [(9, "newer\n", 2_000), (10, "older\n", 1_000)] { + let path = dir.path().join(format!("{REPORT_PREFIX}{pid}.txt")); + std::fs::write(&path, text).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("older\n\nnewer\n")); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "newer\n"); +} + +#[test] +fn a_report_that_cannot_be_moved_is_offered_once() { + let dir = tempfile::tempdir().unwrap(); + // Something that is not a file is in the way. + std::fs::create_dir_all(dir.path().join(PREVIOUS_FILE).join("x")).unwrap(); + let left = dir.path().join(format!("{REPORT_PREFIX}5.txt")); + std::fs::write(&left, "kept\n").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("kept\n")); + assert!(!left.exists()); + assert_eq!(take_previous(dir.path()), None); +} + +#[test] +fn reports_beyond_the_newest_are_removed_not_left_for_a_later_launch() { + let dir = tempfile::tempdir().unwrap(); + let count = MAX_REPORT_FILES + 3; + for n in 0..count { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, format!("report {n}\n")).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(1_000 + n as u64)).unwrap(); + } + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("report 3\n"), "{text}"); + assert!(text.ends_with(&format!("report {}\n", count - 1)), "{text}"); + assert!(!text.contains("report 2\n")); + assert_eq!(take_previous(dir.path()), None); + let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from(PREVIOUS_FILE)]); +} + +#[test] +fn newer_empty_files_do_not_push_out_an_abandoned_report() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join(format!("{REPORT_PREFIX}1.txt")); + std::fs::write(&old, "old\n").unwrap(); + std::fs::File::options().write(true).open(&old).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(1_000)).unwrap(); + for n in 0..MAX_REPORT_FILES + 1 { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, "").unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(2_000)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("old\n")); +} + +#[test] +fn a_file_system_without_locks_does_not_hide_a_report() { + use std::io::{Error, ErrorKind}; + assert!(abandoned(Ok(()))); + assert!(!abandoned(Err(std::fs::TryLockError::WouldBlock))); + assert!(abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::Unsupported))))); + // Any other error may hide a live session's lock: the file is left alone. + assert!(!abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::PermissionDenied))))); +} + +#[test] +fn an_oversized_report_file_is_not_read() { + let dir = tempfile::tempdir().unwrap(); + let big = vec![b'x'; usize::try_from(MAX_REPORT_BYTES).unwrap() + 1]; + let left = dir.path().join(format!("{REPORT_PREFIX}7.txt")); + std::fs::write(&left, big).unwrap(); + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("The report was larger than")); + assert!(!left.exists()); +} + +#[test] +fn troubleshooting_info_holds_both_sessions_reports() { + let shared = shared(Some("VectorCraft hang report, last session\n".to_string())); + *shared.last.lock().unwrap() = Some("VectorCraft hang report, this session\n".to_string()); + let beacon = Beacon::new(); + let text = troubleshooting(&shared, &beacon, UNIX_EPOCH); + assert!(text.starts_with("VectorCraft troubleshooting info, 1970-01-01T00:00:00.000Z\n")); + assert!(text.contains("This session's last hang report:\nVectorCraft hang report, this session\n")); + assert!(text.contains("(it was closed while it did not answer):\nVectorCraft hang report, last session\n")); + assert!(text.contains("Recent commands, oldest first: none")); +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde7..88dd4e6c08 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -139,6 +139,8 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> { + // The dialog waits for the user: not a stall for the hang report (`hang_watch`). + let _mark = vectorcraft_ui_egui::hang_beacon::BEACON.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); show_dialog(file_dialog(&request, parent), &request, parent) } @@ -363,7 +365,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) })), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard(clip)), + // Its calls are marked for the hang report (`hang_watch`). + system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard // owner that never answers holds a read for up to 4 s. Windows only asks which formats the // clipboard holds and macOS asks the pasteboard server, so they check in line. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1cc..33e03b1f72 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -30,6 +30,7 @@ mod control_server; mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; +mod hang_watch; #[cfg(target_os = "linux")] mod linux_stylus; mod logging; @@ -123,6 +124,8 @@ struct App { held_files: Vec<String>, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). + frame_start: Option<std::time::Instant>, } /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. @@ -293,6 +296,11 @@ fn main() -> std::process::ExitCode { None => logger.no_file(), } } + // The report a session left when it was closed while it did not answer (`hang_watch`). + let previous_hang = log_dir().filter(|_| prefs_enabled()).and_then(|dir| hang_watch::take_previous(&dir)); + if let Some(report) = &previous_hang { + log::warn!("the last session was closed while it did not answer; its report:\n{}", report.trim_end()); + } // The roots confine the control channel: a bad one keeps the app from starting rather than // leaving agents unconfined. Without a control port there is nothing to confine. let roots = if control_port.is_none() { @@ -437,6 +445,9 @@ fn main() -> std::process::ExitCode { open_documents::install(); #[cfg(feature = "wgpu")] let created = startup.clone(); + // For the hang report (`hang_watch`). + #[cfg(feature = "wgpu")] + let requested_surface = options.wgpu_options.surface; // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash. let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| { #[cfg(target_os = "linux")] @@ -537,6 +548,36 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + // The hang report and Help › Copy Troubleshooting Info (`hang_watch`). + // The surface settings the options asked for; the driver resolves the present mode. + #[cfg(feature = "wgpu")] + let surface = cc + .wgpu_render_state + .as_ref() + .map(|_| (format!("{:?}", requested_surface.present_mode), requested_surface.desired_maximum_frame_latency)); + #[cfg(not(feature = "wgpu"))] + let surface = None; + let hang = std::sync::Arc::new(hang_watch::Shared { + facts: hang_watch::Facts::new( + |name| std::env::var(name).ok(), + std::path::Path::new("/.flatpak-info").exists(), + hang_watch::window_system(winit_window.as_deref()), + app.graphics_adapter.clone(), + hang_watch::surface(surface), + ), + started: std::time::Instant::now(), + last: std::sync::Mutex::new(None), + previous: previous_hang.clone(), + }); + vectorcraft_ui_egui::hang_beacon::watch_input(&vectorcraft_ui_egui::hang_beacon::BEACON, &cc.egui_ctx); + hang_watch::start(log_dir().filter(|_| prefs_enabled()), hang.clone()); + app.services.troubleshooting = Some(Box::new(move || { + hang_watch::troubleshooting(&hang, &vectorcraft_ui_egui::hang_beacon::BEACON, std::time::SystemTime::now()) + })); + // A message about the graphics processor, or a stopped plug-in, comes first. + if previous_hang.is_some() && app.ui.status.is_empty() { + vectorcraft_ui_egui::troubleshooting::last_session_stopped(&mut app); + } app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; app.desktop_frame = !custom_titlebar; @@ -583,6 +624,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + frame_start: None, })) }), ) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7b69fbfb8d..3ce7143224 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -135,6 +135,7 @@ impl VectorcraftApp { last_window_title: String::new(), fullscreen_for_mode: false, graphics_adapter: None, + beacon: &crate::hang_beacon::BEACON, place: Default::default(), system_paste: false, clipboard_image_request: None, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d97d111812..3a661a7d89 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -73,6 +73,7 @@ fn substitute_warnings_follow_current_font_inventory() { impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> { + self.beacon.command(id); let touch_workspace = self.session.prefs.touch_workspace; // A file dialog it shows off the UI thread runs it again with the path picked. let result = if self.picks.is_entry_free() { diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs new file mode 100644 index 0000000000..c681bc8131 --- /dev/null +++ b/crates/ui-egui/src/hang_beacon.rs @@ -0,0 +1,557 @@ +//! Where the interface thread is in its frame, for the desktop app's hang report (the app's +//! `hang_watch` module). +//! +//! The interface thread marks each stage of a frame (the host's input hook, the app's logic, the +//! interface, and the hand-off to the renderer), a system clipboard call in progress, whether any +//! window's pass had input ([`watch_input`]), and each command it runs. Another thread reads the +//! marks without waiting for the interface thread: the stage and the activity are atomics, and the +//! recent commands and frame times are read with `try_lock`. Marking costs a few atomic stores a +//! frame and one short lock per command and per frame. The web build marks the same way, and +//! nothing reads the marks there. +//! +//! Commands are kept by id only: no parameters, file names or document contents. A command run +//! again moves to the end of the list instead of taking another place, so a panel that runs a +//! query every frame does not push out the commands before it. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use vectorcraft_engine::cmd::clipboard::Flavour; + +use crate::sysclip::{ClipboardRead, SystemClipboard}; + +/// How many different command ids [`Beacon::recent_commands`] keeps. +pub const RECENT_COMMANDS: usize = 8; +/// How many frames [`Beacon::recent_frames`] keeps. +pub const RECENT_FRAMES: usize = 16; +/// The longest command id [`Beacon::command`] keeps as it is. +pub const MAX_COMMAND_ID: usize = 64; + +/// The marks the desktop app's interface thread writes. +pub static BEACON: Beacon = Beacon::new(); + +/// The part of a frame the interface thread is in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Stage { + /// Outside the app's own frame code: waiting for input or a repaint, or a frame without input + /// handed to the renderer. + Idle = 0, + /// The host's input hook. + Input = 1, + /// The app's logic: background results, timers, the control channel. + Logic = 2, + /// The interface: input handling, commands, panels and dialogs. + Interface = 3, + /// A frame that had input was handed to the renderer. Drawing and presenting it follow, and + /// then the next frame, which input asks for at once. + Drawing = 4, +} + +impl Stage { + fn from_u8(n: u8) -> Stage { + match n { + 1 => Stage::Input, + 2 => Stage::Logic, + 3 => Stage::Interface, + 4 => Stage::Drawing, + _ => Stage::Idle, + } + } + + /// Whether the interface thread is expected to finish this stage soon. + pub fn busy(self) -> bool { + self != Stage::Idle + } + + /// The stage for a report, in English. + pub fn describe(self) -> &'static str { + match self { + Stage::Idle => "between frames", + Stage::Input => "reading the window's input", + Stage::Logic => "running the app's logic (background results, timers, the control channel)", + Stage::Interface => "running the interface (input handling, commands, panels and dialogs)", + Stage::Drawing => { + "after a frame with input went to the renderer: drawing or presenting it, or waiting for the next frame, which the system may hold back while the window is hidden" + } + } + } +} + +/// What the interface thread is waiting on inside a stage, where the app knows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Activity { + None = 0, + /// A read of the system clipboard, or a check of what it holds. + ClipboardRead = 1, + /// A write to the system clipboard. + ClipboardWrite = 2, + /// A system file dialog shown in line, which waits for the user and keeps the window + /// answering the system ([`Sample::busy`]). + Dialog = 3, + /// A menu of the system menu bar is open, which waits for the user ([`Sample::busy`]). The + /// host sets it on the sample it reads; the interface thread does not mark it. + Menu = 4, +} + +impl Activity { + fn from_u8(n: u8) -> Activity { + match n { + 1 => Activity::ClipboardRead, + 2 => Activity::ClipboardWrite, + 3 => Activity::Dialog, + 4 => Activity::Menu, + _ => Activity::None, + } + } + + /// Whether the activity waits for the user, so a long one is not a stall. + pub fn waits_for_user(self) -> bool { + matches!(self, Activity::Dialog | Activity::Menu) + } + + /// The activity for a report, in English; `None` when there is none. + pub fn describe(self) -> Option<&'static str> { + match self { + Activity::None => None, + Activity::ClipboardRead => Some("reading the system clipboard"), + Activity::ClipboardWrite => Some("writing to the system clipboard"), + Activity::Dialog => Some("a system file dialog, waiting for the user"), + Activity::Menu => Some("a menu of the system menu bar, waiting for the user"), + } + } +} + +/// What the interface thread was doing when it was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sample { + /// Counts every mark: the same value in two samples means the thread made no mark between them. + pub step: u64, + pub stage: Stage, + pub activity: Activity, +} + +impl Sample { + /// Whether the interface thread is expected to move on soon: inside a stage, and not showing a + /// system dialog or menu that waits for the user. + pub fn busy(&self) -> bool { + self.stage.busy() && !self.activity.waits_for_user() + } +} + +/// One frame's times, in milliseconds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameTime { + /// The app's own work: from the input hook to the end of the interface. + pub work_ms: f32, + /// The renderer's measure of the frame before: the app's work and drawing, without waiting for + /// the display. `None` when the host has none. + pub previous_total_ms: Option<f32>, +} + +/// The marks: see the module documentation. +pub struct Beacon { + step: AtomicU64, + stage: AtomicU8, + activity: AtomicU8, + documents: AtomicUsize, + /// A pass of some window had input since [`Beacon::take_input`] last ran. + input: AtomicBool, + commands: Mutex<VecDeque<String>>, + frames: Mutex<VecDeque<FrameTime>>, +} + +impl Default for Beacon { + fn default() -> Self { + Self::new() + } +} + +impl Beacon { + pub const fn new() -> Beacon { + Beacon { + step: AtomicU64::new(0), + stage: AtomicU8::new(Stage::Idle as u8), + activity: AtomicU8::new(Activity::None as u8), + documents: AtomicUsize::new(0), + input: AtomicBool::new(false), + commands: Mutex::new(VecDeque::new()), + frames: Mutex::new(VecDeque::new()), + } + } + + fn bump(&self) { + self.step.fetch_add(1, Ordering::Release); + } + + /// The interface thread enters `stage`. + pub fn enter(&self, stage: Stage) { + self.stage.store(stage as u8, Ordering::Relaxed); + self.bump(); + } + + /// The interface thread starts `activity`; it ends when the guard is dropped, and the activity + /// before it applies again. + pub fn activity(&self, activity: Activity) -> ActivityGuard<'_> { + let before = Activity::from_u8(self.activity.swap(activity as u8, Ordering::Relaxed)); + self.bump(); + ActivityGuard { beacon: self, before } + } + + /// The interface thread runs the command `id`. An id that is not a command id's shape (longer + /// than [`MAX_COMMAND_ID`] characters, or with characters other than ASCII letters, digits, `.`, + /// `_` and `-`) is kept as `(other)`: ids can come from saved files and the control channel. An + /// id already in the list moves to its end. + pub fn command(&self, id: &str) { + self.bump(); + let shaped = id.len() <= MAX_COMMAND_ID && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + let kept = if shaped { id } else { "(other)" }; + let mut commands = self.commands.lock().unwrap_or_else(PoisonError::into_inner); + if commands.back().is_some_and(|last| last == kept) { + return; + } + let entry = match commands.iter().position(|c| c == kept) { + Some(i) => commands.remove(i).unwrap_or_else(|| kept.to_string()), + None => { + if commands.len() == RECENT_COMMANDS { + commands.pop_front(); + } + kept.to_string() + } + }; + commands.push_back(entry); + } + + /// A pass of some window had input ([`watch_input`]). + pub fn input(&self) { + self.input.store(true, Ordering::Relaxed); + } + + /// Whether a pass of some window had input since the last call. + pub fn take_input(&self) -> bool { + self.input.swap(false, Ordering::Relaxed) + } + + /// A frame ended with `time`, with `documents` documents open. + pub fn frame(&self, time: FrameTime, documents: usize) { + self.documents.store(documents, Ordering::Relaxed); + let mut frames = self.frames.lock().unwrap_or_else(PoisonError::into_inner); + if frames.len() == RECENT_FRAMES { + frames.pop_front(); + } + frames.push_back(time); + } + + /// The current marks. + pub fn sample(&self) -> Sample { + let step = self.step.load(Ordering::Acquire); + Sample { step, stage: Stage::from_u8(self.stage.load(Ordering::Relaxed)), activity: Activity::from_u8(self.activity.load(Ordering::Relaxed)) } + } + + /// The ids of the last [`RECENT_COMMANDS`] commands, oldest first; `None` while the interface + /// thread is writing them. + pub fn recent_commands(&self) -> Option<Vec<String>> { + let commands = match self.commands.try_lock() { + Ok(c) => c, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(commands.iter().cloned().collect()) + } + + /// The last [`RECENT_FRAMES`] frames' times, oldest first; `None` while the interface thread is + /// writing them. + pub fn recent_frames(&self) -> Option<Vec<FrameTime>> { + let frames = match self.frames.try_lock() { + Ok(f) => f, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(frames.iter().copied().collect()) + } + + /// The documents open at the end of the last frame. + pub fn documents(&self) -> usize { + self.documents.load(Ordering::Relaxed) + } +} + +/// Whether a pass's input asks for the next frame at once: events, or files dropped or hovered. +pub fn had_input(raw: &egui::RawInput) -> bool { + !raw.events.is_empty() || !raw.dropped_files.is_empty() || !raw.hovered_files.is_empty() +} + +/// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main +/// window's, after the host's input hook added its own, and those of the windows shown in line +/// (floating panels and documents), whose input does not pass through that hook. +pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { + ctx.on_begin_pass( + "VectorCraft hang report input", + std::sync::Arc::new(move |ui| { + if ui.ctx().input(|i| had_input(&i.raw)) { + beacon.input(); + } + }), + ); +} + +/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and the main +/// window is `shown` (focused, not minimized or hidden), since input asks for the next frame at +/// once; otherwise [`Stage::Idle`]. A frame that sends the main window `commands` that minimize or +/// hide it, or hand it to the system to move or resize, also ends idle: the system may hold back +/// the next frame until the user is done. +pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { + use egui::ViewportCommand as C; + let handed_off = commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))); + if input && shown && !handed_off { Stage::Drawing } else { Stage::Idle } +} + +/// Ends an [`Activity`] when dropped ([`Beacon::activity`]). +pub struct ActivityGuard<'a> { + beacon: &'a Beacon, + before: Activity, +} + +impl Drop for ActivityGuard<'_> { + fn drop(&mut self) { + self.beacon.activity.store(self.before as u8, Ordering::Relaxed); + self.beacon.bump(); + } +} + +/// A [`SystemClipboard`] whose calls are marked on [`BEACON`] as clipboard activity. The desktop +/// app wraps the clipboard the interface thread uses; the background probe's own clipboard stays +/// unmarked. +pub struct Marked(pub Box<dyn SystemClipboard>); + +impl SystemClipboard for Marked { + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let _mark = BEACON.activity(Activity::ClipboardWrite); + self.0.write(flavours) + } + + fn holds_ours(&mut self) -> bool { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.holds_ours() + } + + fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.read(mimes) + } + + fn has(&mut self, mimes: &[&'static str]) -> bool { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.has(mimes) + } + + fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.read_deferred(mimes) + } + + fn change_count(&mut self) -> Option<u64> { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.change_count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_mark_moves_the_step_and_the_stage_reads_back() { + let b = Beacon::new(); + let first = b.sample(); + assert_eq!((first.stage, first.activity), (Stage::Idle, Activity::None)); + b.enter(Stage::Interface); + let s = b.sample(); + assert!(s.step > first.step); + assert_eq!(s.stage, Stage::Interface); + b.command("edit.copy"); + assert!(b.sample().step > s.step); + b.enter(Stage::Idle); + assert!(!b.sample().stage.busy()); + } + + #[test] + fn an_activity_ends_with_its_guard_and_restores_the_one_before() { + let b = Beacon::new(); + { + let _write = b.activity(Activity::ClipboardWrite); + { + let _read = b.activity(Activity::ClipboardRead); + assert_eq!(b.sample().activity, Activity::ClipboardRead); + } + assert_eq!(b.sample().activity, Activity::ClipboardWrite); + } + assert_eq!(b.sample().activity, Activity::None); + } + + #[test] + fn only_the_last_commands_and_frames_are_kept_oldest_first() { + let b = Beacon::new(); + for n in 0..RECENT_COMMANDS + 3 { + b.command(&format!("cmd.{n}")); + } + let commands = b.recent_commands().unwrap_or_default(); + assert_eq!(commands.len(), RECENT_COMMANDS); + assert_eq!(commands.first().map(String::as_str), Some("cmd.3")); + assert_eq!(commands.last().map(String::as_str), Some("cmd.10")); + for n in 0..RECENT_FRAMES + 2 { + b.frame(FrameTime { work_ms: n as f32, previous_total_ms: None }, 2); + } + let frames = b.recent_frames().unwrap_or_default(); + assert_eq!(frames.len(), RECENT_FRAMES); + assert_eq!(frames.first().map(|f| f.work_ms), Some(2.0)); + assert_eq!(b.documents(), 2); + } + + #[test] + fn a_command_run_every_frame_does_not_push_out_the_ones_before() { + let b = Beacon::new(); + for n in 0..RECENT_COMMANDS - 2 { + b.command(&format!("cmd.{n}")); + } + // Two panels that each run a query every frame. + for _ in 0..100 { + b.command("symbol.list"); + b.command("swatch.list"); + } + b.command("cmd.0"); + let commands = b.recent_commands().unwrap_or_default(); + let want: Vec<String> = ["cmd.1", "cmd.2", "cmd.3", "cmd.4", "cmd.5", "symbol.list", "swatch.list", "cmd.0"].map(String::from).to_vec(); + assert_eq!(commands, want); + } + + #[test] + fn a_frame_with_input_ends_drawing_unless_the_window_is_hidden_or_handed_to_the_system() { + use egui::ViewportCommand as C; + assert_eq!(after_frame(true, true, &[]), Stage::Drawing); + assert_eq!(after_frame(true, true, &[C::Focus]), Stage::Drawing); + assert_eq!(after_frame(false, true, &[]), Stage::Idle); + assert_eq!(after_frame(true, false, &[]), Stage::Idle); + for c in [C::Minimized(true), C::Visible(false), C::StartDrag, C::BeginResize(egui::ResizeDirection::East)] { + assert_eq!(after_frame(true, true, std::slice::from_ref(&c)), Stage::Idle, "{c:?}"); + } + } + + #[test] + fn input_is_events_or_files() { + let mut raw = egui::RawInput::default(); + assert!(!had_input(&raw)); + raw.hovered_files.push(egui::HoveredFile::default()); + assert!(had_input(&raw)); + #[derive(Debug)] + struct Dropped; + impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + std::path::Path::new("a.svg") + } + fn bytes(&self) -> Result<Vec<u8>, String> { + Ok(Vec::new()) + } + } + let mut raw = egui::RawInput::default(); + raw.dropped_files.push(std::sync::Arc::new(Dropped)); + assert!(had_input(&raw)); + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::PointerGone); + assert!(had_input(&raw)); + } + + #[test] + fn input_in_a_window_shown_in_line_is_marked() { + static B: Beacon = Beacon::new(); + // The host runs a window shown in line as its own pass, with that window's input. + let child_events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let events = child_events.clone(); + egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { + let id = viewport.ids.this; + let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect(); + let input = egui::RawInput { viewport_id: id, viewports, events: events.take(), ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + watch_input(&B, &ctx); + let child = egui::ViewportId::from_hash_of("floating panel"); + let frame = |main: Vec<egui::Event>| { + ctx.run_ui(egui::RawInput { events: main, ..Default::default() }, |ui| { + ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |_, _| {}); + }) + .textures_delta + .clear(); + }; + frame(Vec::new()); + assert!(!B.take_input(), "no input anywhere"); + child_events.replace(vec![egui::Event::PointerGone]); + frame(Vec::new()); + assert!(B.take_input(), "the floating window's input"); + assert!(!B.take_input(), "taken"); + frame(vec![egui::Event::PointerGone]); + assert!(B.take_input(), "the main window's input"); + } + + #[test] + fn the_marked_clipboard_marks_its_calls() { + type Seen = std::sync::Arc<Mutex<Vec<Activity>>>; + struct Probe(Seen); + impl Probe { + fn see(&self) { + self.0.lock().unwrap().push(BEACON.sample().activity); + } + } + impl SystemClipboard for Probe { + fn write(&mut self, _: &[Flavour]) -> Result<(), String> { + self.see(); + Ok(()) + } + fn holds_ours(&mut self) -> bool { + self.see(); + false + } + fn read(&mut self, _: &[&'static str]) -> Option<Flavour> { + self.see(); + None + } + fn has(&mut self, _: &[&'static str]) -> bool { + self.see(); + false + } + } + let seen = Seen::default(); + let mut m = Marked(Box::new(Probe(seen.clone()))); + let _ = m.write(&[]); + let _ = m.read(&[]); + let _ = m.has(&[]); + let _ = m.holds_ours(); + assert_eq!(BEACON.sample().activity, Activity::None); + assert_eq!(*seen.lock().unwrap(), vec![Activity::ClipboardWrite, Activity::ClipboardRead, Activity::ClipboardRead, Activity::ClipboardRead]); + } + + #[test] + fn ids_that_are_not_command_ids_are_kept_as_other() { + let b = Beacon::new(); + b.command("file.open"); + b.command("/home/someone/private plan.vectorcraft"); + b.command(&"y".repeat(MAX_COMMAND_ID)); + b.command(&"x".repeat(MAX_COMMAND_ID + 1)); + let commands = b.recent_commands().unwrap_or_default(); + // Both are `(other)`, which moves to the end the second time. + assert_eq!(commands, vec!["file.open".to_string(), "y".repeat(MAX_COMMAND_ID), "(other)".into()]); + } + + #[test] + fn a_reader_does_not_wait_while_the_commands_are_being_written() { + let b = Beacon::new(); + b.command("edit.paste"); + let held = b.commands.lock().unwrap_or_else(PoisonError::into_inner); + assert_eq!(b.recent_commands(), None); + drop(held); + assert_eq!(b.recent_commands(), Some(vec!["edit.paste".to_string()])); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..5e084d2fcc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6500,6 +6500,11 @@ breakText Glyphs Глифи No importer plug-ins installed Няма инсталирани плъгини за импортиране Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях +# Copy Troubleshooting Info + Copy Troubleshooting Info Копиране на информация за отстраняване на проблеми +@msg Copied the troubleshooting info Информацията за отстраняване на проблеми е копирана +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последната сесия на VectorCraft спря да отговаря, преди да се затвори. Помощ › Копиране на информация за отстраняване на проблеми копира подробностите за доклад за грешка + # Safe Mode Safe Mode… Безопасен режим… Safe Mode Безопасен режим diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..dd1f2f03d0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6551,6 +6551,11 @@ shape Open Otvoren No importer plug-ins installed Nisu instalirani dodaci za uvoz Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiraj informacije za rješavanje problema +@msg Copied the troubleshooting info Informacije za rješavanje problema kopirane +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Posljednja sesija VectorCrafta prestala je odgovarati prije zatvaranja. Pomoć › Kopiraj informacije za rješavanje problema kopira detalje za prijavu greške + # Safe Mode Safe Mode… Sigurni način rada… Safe Mode Sigurni način rada diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..919aad1894 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6752,6 +6752,11 @@ breakText Glyphs Glifs @msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copia la informació de resolució de problemes +@msg Copied the troubleshooting info S’ha copiat la informació de resolució de problemes +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La darrera sessió del VectorCraft va deixar de respondre abans de tancar-se. Ajuda › Copia la informació de resolució de problemes copia els detalls per a un informe d’error + # Safe Mode Safe Mode… Mode segur… Safe Mode Mode segur diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..94e2db03eb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6771,6 +6771,11 @@ breakText Glyphs Glyfy @msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul @msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopírovat informace pro řešení potíží +@msg Copied the troubleshooting info Informace pro řešení potíží zkopírovány +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Poslední relace VectorCraftu přestala odpovídat, než se zavřela. Nápověda › Kopírovat informace pro řešení potíží zkopíruje podrobnosti pro hlášení chyby + # Safe Mode Safe Mode… Nouzový režim… Safe Mode Nouzový režim diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..2284f4c363 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6514,6 +6514,11 @@ breakText Glyphs Glyffer No importer plug-ins installed Ingen import-plug-ins installeret Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiér fejlfindingsoplysninger +@msg Copied the troubleshooting info Kopierede fejlfindingsoplysningerne +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den seneste VectorCraft-session holdt op med at svare, før den blev lukket. Hjælp › Kopiér fejlfindingsoplysninger kopierer detaljerne til en fejlrapport + # Safe Mode Safe Mode… Sikker tilstand… Safe Mode Sikker tilstand diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..210110d1c5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6358,6 +6358,11 @@ breakText Glyphs Glyphen @msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an @msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg +# Copy Troubleshooting Info + Copy Troubleshooting Info Informationen zur Fehlerbehebung kopieren +@msg Copied the troubleshooting info Informationen zur Fehlerbehebung kopiert +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Die letzte VectorCraft-Sitzung reagierte nicht mehr, bevor sie geschlossen wurde. Hilfe › Informationen zur Fehlerbehebung kopieren kopiert die Details für einen Fehlerbericht + # Safe Mode Safe Mode… Abgesicherter Modus… Safe Mode Abgesicherter Modus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..ece0b579c5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6723,6 +6723,11 @@ breakText Glyphs Γλύφοι @msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions` @msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων +@msg Copied the troubleshooting info Οι πληροφορίες αντιμετώπισης προβλημάτων αντιγράφηκαν +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Η τελευταία συνεδρία του VectorCraft σταμάτησε να αποκρίνεται πριν κλείσει. Βοήθεια › Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων αντιγράφει τις λεπτομέρειες για αναφορά σφάλματος + # Safe Mode Safe Mode… Ασφαλής λειτουργία… Safe Mode Ασφαλής λειτουργία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..b987865016 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6714,6 +6714,11 @@ breakText Glyphs Glifos @msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copiar información de solución de problemas +@msg Copied the troubleshooting info Se copió la información de solución de problemas +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La última sesión de VectorCraft dejó de responder antes de cerrarse. Ayuda › Copiar información de solución de problemas copia los detalles para un informe de error + # Safe Mode Safe Mode… Modo seguro… Safe Mode Modo seguro diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..6674e959b7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6806,6 +6806,11 @@ breakText Glyphs Merkit @msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions` @msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopioi vianmääritystiedot +@msg Copied the troubleshooting info Vianmääritystiedot kopioitiin +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraftin edellinen istunto lakkasi vastaamasta ennen kuin se suljettiin. Ohje › Kopioi vianmääritystiedot kopioi tiedot virheraporttia varten + # Safe Mode Safe Mode… Vikasietotila… Safe Mode Vikasietotila diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..7cdb1f8a00 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6766,6 +6766,11 @@ breakText Glyphs Glyphes @msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` @msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copier les informations de dépannage +@msg Copied the troubleshooting info Informations de dépannage copiées +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La dernière session de VectorCraft a cessé de répondre avant de se fermer. Aide › Copier les informations de dépannage copie les détails pour un rapport de bogue + # Safe Mode Safe Mode… Mode sans échec… Safe Mode Mode sans échec diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..2cc8866e79 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8188,6 +8188,11 @@ swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस +# Copy Troubleshooting Info + Copy Troubleshooting Info समस्या निवारण जानकारी कॉपी करें +@msg Copied the troubleshooting info समस्या निवारण जानकारी कॉपी की गई +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft के पिछले सत्र ने बंद होने से पहले प्रतिक्रिया देना बंद कर दिया था। सहायता ›समस्या निवारण जानकारी कॉपी करें बग रिपोर्ट के लिए विवरण कॉपी करता है + # Safe Mode Safe Mode… सुरक्षित मोड… Safe Mode सुरक्षित मोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..04cea3fc88 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6755,6 +6755,11 @@ breakText Glyphs Glifák @msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket @msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot +# Copy Troubleshooting Info + Copy Troubleshooting Info Hibaelhárítási adatok másolása +@msg Copied the troubleshooting info Hibaelhárítási adatok másolva +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A VectorCraft legutóbbi munkamenete nem válaszolt, mielőtt bezárult. A Súgó › Hibaelhárítási adatok másolása kimásolja a részleteket egy hibajelentéshez + # Safe Mode Safe Mode… Csökkentett mód… Safe Mode Csökkentett mód diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..c799fbde3a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6514,6 +6514,11 @@ breakText Glyphs Glif No importer plug-ins installed Tidak ada plug-in impor yang terinstal Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya +# Copy Troubleshooting Info + Copy Troubleshooting Info Salin info pemecahan masalah +@msg Copied the troubleshooting info Berhasil menyalin info pemecahan masalah +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Sesi terakhir VectorCraft berhenti merespons sebelum ditutup. Bantuan › Salin info pemecahan masalah menyalin detailnya untuk laporan bug + # Safe Mode Safe Mode… Mode aman… Safe Mode Mode aman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..0911606b09 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6392,6 +6392,11 @@ breakText Glyphs Glifi @msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copia informazioni per la risoluzione dei problemi +@msg Copied the troubleshooting info Informazioni per la risoluzione dei problemi copiate +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report L’ultima sessione di VectorCraft ha smesso di rispondere prima di chiudersi. Aiuto › Copia informazioni per la risoluzione dei problemi copia i dettagli per una segnalazione di bug + # Safe Mode Safe Mode… Modalità provvisoria… Safe Mode Modalità provvisoria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..7c711fef5e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6457,6 +6457,11 @@ breakText Glyphs 字形 @msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです @msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください +# Copy Troubleshooting Info + Copy Troubleshooting Info トラブルシューティング情報をコピー +@msg Copied the troubleshooting info トラブルシューティング情報をコピーしました +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 前回の VectorCraft のセッションは、閉じる前に応答しなくなりました。ヘルプ › トラブルシューティング情報をコピー で、バグ報告用の詳細をコピーできます + # Safe Mode Safe Mode… セーフモード… Safe Mode セーフモード diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..e64092fb64 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6880,6 +6880,11 @@ font-script Japanese იაპონური @msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს @msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info პრობლემების მოგვარების ინფორმაციის კოპირება +@msg Copied the troubleshooting info პრობლემების მოგვარების ინფორმაცია კოპირებულია +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft-ის ბოლო სესიამ დახურვამდე პასუხის გაცემა შეწყვიტა. დახმარება › პრობლემების მოგვარების ინფორმაციის კოპირება აკოპირებს დეტალებს შეცდომის ანგარიშისთვის + # Safe Mode Safe Mode… უსაფრთხო რეჟიმი… Safe Mode უსაფრთხო რეჟიმი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..201bc2a781 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6353,6 +6353,11 @@ breakText Glyphs 글리프 @msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다 @msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요 +# Copy Troubleshooting Info + Copy Troubleshooting Info 문제 해결 정보 복사 +@msg Copied the troubleshooting info 문제 해결 정보를 복사했습니다 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 지난 VectorCraft 세션이 닫히기 전에 응답을 멈췄습니다. 도움말 › 문제 해결 정보 복사로 버그 보고용 세부 정보를 복사할 수 있습니다 + # Safe Mode Safe Mode… 안전 모드… Safe Mode 안전 모드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..73539d8ebb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6565,6 +6565,11 @@ breakText Glyphs Glyphs No importer plug-ins installed Geen importplug-ins geïnstalleerd Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken +# Copy Troubleshooting Info + Copy Troubleshooting Info Probleemoplossingsgegevens kopiëren +@msg Copied the troubleshooting info Probleemoplossingsgegevens gekopieerd +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report De laatste sessie van VectorCraft reageerde niet meer voordat deze werd gesloten. Help › Probleemoplossingsgegevens kopiëren kopieert de details voor een foutrapport + # Safe Mode Safe Mode… Veilige modus… Safe Mode Veilige modus diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..5e174282a7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6627,6 +6627,11 @@ breakText Glyphs Glify @msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu @msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiuj informacje diagnostyczne +@msg Copied the troubleshooting info Skopiowano informacje diagnostyczne +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Ostatnia sesja VectorCraft przestała odpowiadać przed zamknięciem. Pomoc › Kopiuj informacje diagnostyczne kopiuje szczegóły do zgłoszenia błędu + # Safe Mode Safe Mode… Tryb awaryjny… Safe Mode Tryb awaryjny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..a7f19034f5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6844,6 +6844,11 @@ swatch Yellow Green Verde-amarelado @msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions` @msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copiar informações de solução de problemas +@msg Copied the troubleshooting info Informações de solução de problemas copiadas +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A última sessão do VectorCraft parou de responder antes de fechar. Ajuda › Copiar informações de solução de problemas copia os detalhes para um relatório de bug + # Safe Mode Safe Mode… Modo de Segurança… Safe Mode Modo de Segurança diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..4bc7eef69e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6860,6 +6860,11 @@ shape Open Открытый @msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта @msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Копировать сведения для устранения неполадок +@msg Copied the troubleshooting info Сведения для устранения неполадок скопированы +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последний сеанс VectorCraft перестал отвечать перед закрытием. Справка › Копировать сведения для устранения неполадок копирует подробности для отчёта об ошибке + # Safe Mode Safe Mode… Безопасный режим… Safe Mode Безопасный режим diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..db301f8955 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8256,6 +8256,11 @@ digits Hindi Hindi @msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions` @msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiera felsökningsinformation +@msg Copied the troubleshooting info Kopierade felsökningsinformationen +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den senaste VectorCraft-sessionen slutade svara innan den stängdes. Hjälp › Kopiera felsökningsinformation kopierar detaljerna till en felrapport + # Safe Mode Safe Mode… Felsäkert läge… Safe Mode Felsäkert läge diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..1a9a7fc926 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6727,6 +6727,11 @@ breakText Glyphs Glifler @msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir @msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın +# Copy Troubleshooting Info + Copy Troubleshooting Info Sorun Giderme Bilgilerini Kopyala +@msg Copied the troubleshooting info Sorun giderme bilgileri kopyalandı +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft'ın son oturumu kapanmadan önce yanıt vermeyi durdurdu. Yardım › Sorun Giderme Bilgilerini Kopyala, hata raporu için ayrıntıları kopyalar + # Safe Mode Safe Mode… Güvenli Mod… Safe Mode Güvenli Mod diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..a79305a856 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6372,6 +6372,11 @@ breakText Glyphs Гліфи @msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту @msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Копіювати відомості для усунення несправностей +@msg Copied the troubleshooting info Відомості для усунення несправностей скопійовано +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Останній сеанс VectorCraft перестав відповідати перед закриттям. Довідка › Копіювати відомості для усунення несправностей копіює подробиці для звіту про помилку + # Safe Mode Safe Mode… Безпечний режим… Safe Mode Безпечний режим diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..f186cc5def 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6373,6 +6373,11 @@ breakText Glyphs Glyph @msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions` @msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Sao chép thông tin khắc phục sự cố +@msg Copied the troubleshooting info Đã sao chép thông tin khắc phục sự cố +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Phiên VectorCraft trước đã ngừng phản hồi trước khi đóng. Trợ giúp › Sao chép thông tin khắc phục sự cố sẽ sao chép chi tiết cho báo cáo lỗi + # Safe Mode Safe Mode… Chế độ an toàn… Safe Mode Chế độ an toàn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..0495881d81 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6612,6 +6612,11 @@ breakText Glyphs 字形 @msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions` @msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info 复制故障排除信息 +@msg Copied the troubleshooting info 已复制故障排除信息 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 会话在关闭前停止了响应。帮助 › 复制故障排除信息 可复制用于错误报告的详细信息 + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..a80fa13dc1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6410,6 +6410,11 @@ breakText Glyphs 字符 @msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions` @msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info 複製疑難排解資訊 +@msg Copied the troubleshooting info 已複製疑難排解資訊 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 工作階段在關閉前停止回應。說明 › 複製疑難排解資訊 可複製用於錯誤回報的詳細資料 + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..b5035ee12c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,7 @@ pub mod font_menu; mod frame_rate; mod free_transform; pub mod graphics; +pub mod hang_beacon; pub mod i18n; pub mod icon_data; pub mod icons; @@ -80,6 +81,7 @@ pub mod toolbar; mod tools_float; pub mod touch; pub mod trace_runs; +pub mod troubleshooting; mod ui_fonts; pub mod unsaved; pub mod updates_ui; @@ -222,6 +224,9 @@ pub struct VectorcraftApp { /// The graphics adapter the window renders with ("name (backend)"), as the host reports it: /// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown. pub graphics_adapter: Option<String>, + /// Where [`Self::run`] marks each command for the desktop app's hang report: + /// [`hang_beacon::BEACON`], or a test's own. + pub(crate) beacon: &'static hang_beacon::Beacon, /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, /// The Paste commands can paste from the system clipboard alone: it holds something to paste diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..1e84840b9a 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -656,6 +656,7 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< } "help.checkForUpdates" => crate::updates_ui::check(app), "help.safeMode" => crate::dialogs::safe_mode::open(app), + "help.copyTroubleshootingInfo" => crate::troubleshooting::copy(app), "help.commandPalette" => { app.ui.palette_open = !app.ui.palette_open; app.ui.palette_query.clear(); diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 0c46950f37..4a1c66a292 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -712,6 +712,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> { c("Search Commands…", "help.commandPalette"), todos("VectorCraft Help…", "F1"), c("Safe Mode…", "help.safeMode"), + c("Copy Troubleshooting Info", "help.copyTroubleshootingInfo"), Sep, c("Check for Updates…", "help.checkForUpdates"), c("About VectorCraft", "help.about"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67c..9c9ba1f938 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -743,6 +743,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", ), + ( + "help.copyTroubleshootingInfo", + "Copy Troubleshooting Info", + "", + "{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions", diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..45395b8fa7 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -179,6 +179,10 @@ pub struct Services { /// reports for a window on that monitor. Empty on the web, and where the host cannot place a /// window by these points, which sends floating panels to the main window's monitor. pub dock_monitors: Vec<craft_ui::docking::Monitor>, + /// The text Help › Copy Troubleshooting Info copies (desktop: the platform, the graphics + /// processor, recent commands and frame times, and the hang reports of this session and the + /// one before). Without it the command copies the version only ([`crate::troubleshooting`]). + pub troubleshooting: Option<Box<dyn Fn() -> String>>, } /// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). diff --git a/crates/ui-egui/src/troubleshooting.rs b/crates/ui-egui/src/troubleshooting.rs new file mode 100644 index 0000000000..5624a7996d --- /dev/null +++ b/crates/ui-egui/src/troubleshooting.rs @@ -0,0 +1,72 @@ +//! Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`): puts the text the host +//! gives ([`crate::Services::troubleshooting`]) on the clipboard for a bug report. The desktop app +//! gives the platform, the graphics processor, recent commands and frame times, and the hang reports +//! of this session and the one before (its `hang_watch` module); without the service the text is +//! the version only. + +use serde_json::{Value, json}; + +use crate::VectorcraftApp; + +/// The status message after the text was put on the clipboard. +pub const COPIED: &str = "Copied the troubleshooting info"; + +/// The status message at launch when the last session stopped responding and was closed before it +/// answered again. +pub const LAST_SESSION_STOPPED: &str = + "The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report"; + +/// The text the command copies. +pub fn text(app: &VectorcraftApp) -> String { + match &app.services.troubleshooting { + Some(text) => text(), + None => format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")), + } +} + +/// `help.copyTroubleshootingInfo`: put [`text`] on the clipboard. → `{chars}`, the text's length in +/// characters. +pub fn copy(app: &mut VectorcraftApp) -> Result<Value, String> { + let text = text(app); + let chars = text.chars().count(); + app.clipboard_out = Some(text); + app.status(COPIED); + Ok(json!({ "chars": chars })) +} + +/// Tell the user, at launch, that the last session stopped responding and was closed. +pub fn last_session_stopped(app: &mut VectorcraftApp) { + app.status(LAST_SESSION_STOPPED); +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + + #[test] + fn the_command_copies_the_hosts_text_and_says_so() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.troubleshooting = Some(Box::new(|| "platform: test\n".to_string())); + let r = app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(r["chars"], 15); + assert_eq!(app.clipboard_out.as_deref(), Some("platform: test\n")); + assert_eq!(app.ui.status, COPIED); + } + + #[test] + fn without_the_service_the_version_is_copied() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(app.clipboard_out, Some(format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")))); + } + + #[test] + fn running_a_command_marks_it_for_the_hang_report() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.beacon = Box::leak(Box::new(crate::hang_beacon::Beacon::new())); + app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(app.beacon.recent_commands(), Some(vec!["help.copyTroubleshootingInfo".to_string()])); + } +} diff --git a/docs/development.md b/docs/development.md index c6523bd035..bdab8c1746 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,6 +89,8 @@ The desktop app writes its `log` records to standard error and to `logs/vectorcr By default VectorCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,vectorcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`vectorcraft*=debug`). The logger is `apps/vectorcraft/src/logging.rs`. `vectorcraft-cli mcp` has its own logger, which sends records to the MCP client (`docs/mcp.md`). +When the interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-<process id>.txt`, which the process keeps locked. The report gives the part of the frame (the input hook, the app's logic, the interface, or the renderer after a frame in which any window had input), a system clipboard read or write in progress, the last 8 different command ids (a command run again moves to the end of the list), the times of the last 16 frames, the platform, the window system (Wayland, X11, AppKit, Win32, other or unknown), `XDG_CURRENT_DESKTOP`, `XDG_SESSION_TYPE`, whether the app runs in a Flatpak, the graphics adapter, the present mode and frame latency the app asks the window surface for, the session's running time and how many documents are open. It holds no document contents, file paths or command parameters, and no native stack. The time after a frame that minimizes or hides the main window, or starts moving or resizing it, is not timed, and neither is an in-line file dialog (Windows and Linux) or an open menu of the macOS menu bar. When the interface answers again, the log records after how long and the file is removed. A file that no process holds locked at a later launch was left by a session that was closed while it did not answer (a locked one belongs to another instance and is left alone): that launch writes the report to the log, moves the file to `logs/hang-report.1.txt` (older abandoned reports beyond the newest 16 are removed) and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the hang reports of this session and the last one, for a bug report. The watch is `apps/vectorcraft/src/hang_watch.rs`; the marks it reads are `crates/ui-egui/src/hang_beacon.rs`. + ## Environment variables (desktop app) | Variable | Effect | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..f96bfefc58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1652,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. +- **Hang report and Copy Troubleshooting Info:** when the desktop app's interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-<process id>.txt`, and removes the file when the interface answers again. A later launch that finds a report no process holds writes it to the log, keeps it as `logs/hang-report.1.txt` and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the reports of this session and the last one. Regressions: `apps/vectorcraft/src/hang_watch/tests.rs`, `crates/ui-egui/src/hang_beacon.rs`, `crates/ui-egui/src/troubleshooting.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. From d8be61ddb19db39cf28488c5e5af235dab59daf9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:27:24 -0800 Subject: [PATCH 020/126] Help: making a save dialog's folder is timed for the hang report An in-line save dialog that first makes its folder marked the folder work as the dialog too, so a stall in making the folder wrote no hang report. The folder is now made before the dialog is marked, on the background dialog thread as well, and a test checks the order. --- apps/vectorcraft/src/host_services.rs | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 88dd4e6c08..50f1792eb8 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -120,7 +120,6 @@ fn make_folder(request: &PickRequest) { #[cfg(not(target_os = "macos"))] #[cfg_attr(windows, allow(unused_variables))] fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> { - make_folder(request); #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] if let PickRequest::Save(pick) = request && let Some(answer) = portal_save::pick(pick, parent.as_deref()) @@ -139,9 +138,30 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> { - // The dialog waits for the user: not a stall for the hang report (`hang_watch`). - let _mark = vectorcraft_ui_egui::hang_beacon::BEACON.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); - show_dialog(file_dialog(&request, parent), &request, parent) + let beacon = &vectorcraft_ui_egui::hang_beacon::BEACON; + marked_dialog(beacon, || make_folder(&request), || show_dialog(file_dialog(&request, parent), &request, parent)) +} + +/// Run `prepare` (making the dialog's folder), then `show`, marked on `beacon` as a dialog: the +/// dialog waits for the user and is not a stall for the hang report (`hang_watch`), while `prepare` +/// is still timed. +#[cfg(not(target_os = "macos"))] +fn marked_dialog<T>(beacon: &vectorcraft_ui_egui::hang_beacon::Beacon, prepare: impl FnOnce(), show: impl FnOnce() -> T) -> T { + prepare(); + let _mark = beacon.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); + show() +} + +#[cfg(all(test, not(target_os = "macos")))] +#[test] +fn making_a_dialogs_folder_is_timed_and_the_dialog_is_not() { + use vectorcraft_ui_egui::hang_beacon::{Activity, Beacon}; + let beacon = Beacon::new(); + let mut preparing = None; + let showing = marked_dialog(&beacon, || preparing = Some(beacon.sample().activity), || beacon.sample().activity); + assert_eq!(preparing, Some(Activity::None), "making the folder is timed"); + assert_eq!(showing, Activity::Dialog, "the dialog is not"); + assert_eq!(beacon.sample().activity, Activity::None, "the mark ends with the dialog"); } /// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an @@ -241,6 +261,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti std::thread::Builder::new() .name("file-dialog".into()) .spawn(move || { + make_folder(&request); // The app gone meanwhile has no use for the answer. let _ = tx.send(show_dialog(dialog, &request, &parent)); ui.request_repaint(); From db8726e94bd5bf32d4a4a99218afaf60809a16c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:33:37 -0800 Subject: [PATCH 021/126] MCP: replies hide library paths outside the read roots in more forms - Every request under a read root records the hidden libraries before it runs, and the reply hides those and the ones hidden after it. A panic is answered inside the request, so its error is hidden too. - A hidden path followed by more path text stays only when the longer text is another loaded library's id with no `.` or `..` part. - Encoded file data is skipped only for a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`; objects and lists under those keys, and other keys ending in `Base64`, are searched. - The error of a failed request hides a path outside the read roots that the request sent by its file name, so it reads the same whether or not a library was loaded from that path. - Paths written with `%XX` escapes are hidden, and on Windows paths in another letter case. - Recorded steps in `ui.inspect` leave out `~`, paths under `~` and paths starting with `./` or `../` outside the read roots under any param. - Hidden libraries are numbered in load order and keep their id from one request to the next. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/control.rs | 445 ++++++++++++++++++++----- crates/ui-egui/src/control/tests.rs | 126 ++++++- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/tests/automation.rs | 114 ++++++- docs/mcp.md | 27 +- 6 files changed, 609 insertions(+), 108 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7b69fbfb8d..02dd92de2a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -115,6 +115,7 @@ impl VectorcraftApp { synthetic_dropped_before: 0, pending_screenshots: vec![], pointer_replies: vec![], + shown_library_ids: Default::default(), queued_screenshots: vec![], screenshot_token: 0, screenshot_reserved: 0, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c06951cee5..4f66142e1c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -326,30 +326,40 @@ const PANEL_FIELDS: [&str; 7] = /// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a /// command palette query, a workspace's or an action's name) is not read. When another of those ids, /// or a library loaded in the session, already has that id, the next free one -/// of `loaded/<file name> (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that -/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. -fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { - let mut ids = std::collections::BTreeSet::new(); +/// of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two and +/// `ui.inspect` and `ui.docking` show a library under the same id. A library keeps its id from one +/// request to the next while it is referred to. New ids are numbered in this order: the libraries +/// loaded in the session (the swatch, graphic style, symbol and brush libraries, each kind in the +/// order it was loaded), then the library panels in their order, the Color Guide's limit and the +/// other ids the UI state holds. +fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { + let mut rest = std::collections::BTreeSet::new(); let ui = serde_json::to_value(&app.ui).unwrap_or_default(); for field in PANEL_FIELDS { - collect_library_panel_ids(&ui[field], &mut ids); + collect_library_panel_ids(&ui[field], &mut rest); } - collect_library_params(&ui["action_sets"], &mut ids); + collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { - collect_library_panel_ids(layout, &mut ids); + collect_library_panel_ids(layout, &mut rest); } } - collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest); // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded // without a path. let s = &app.session; let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { - add_library_id(id, &mut ids); + let mut ids: Vec<String> = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + let first = loaded.iter().copied().chain(app.ui.library_panels.iter().map(|p| p.id.as_str())).chain([app.ui.color_guide_limit.as_str()]); + for id in first.map(str::to_string).chain(rest) { + if id.len() > "loaded/".len() && id.starts_with("loaded/") && seen.insert(id.clone()) { + ids.push(id); + } } let mut hidden = Vec::new(); + let mut readable_ids = std::collections::BTreeSet::new(); let mut taken: std::collections::BTreeSet<String> = loaded.iter().map(|id| id.to_string()).collect(); for id in ids { let path = &id["loaded/".len()..]; @@ -358,25 +368,110 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { if name != path && !readable(path) { hidden.push((id, name)); } else { - taken.insert(id); + taken.insert(id.clone()); + readable_ids.insert(id); } } - let mut hidden = hidden - .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); - (id, shown) - }) - .collect::<Vec<_>>(); + // A library keeps the id it was shown under while it is still referred to and no library loaded + // without a path, or one the request may read, has taken that id since. + let mut remembered = app.shown_library_ids.borrow_mut(); + remembered.retain(|(id, shown)| hidden.iter().any(|(h, _)| h == id) && !taken.contains(shown)); + taken.extend(remembered.iter().map(|(_, shown)| shown.clone())); + let mut shown_ids = Vec::with_capacity(hidden.len()); + for (id, name) in hidden { + if let Some((_, shown)) = remembered.iter().find(|(known, _)| *known == id) { + shown_ids.push((id, shown.clone())); + continue; + } + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + remembered.push((id.clone(), shown.clone())); + shown_ids.push((id, shown)); + } + let mut hidden = shown_ids; // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - hidden + Hiding { hidden, readable: readable_ids } +} + +/// What a reply hides ([`hide_library_ids`]). +#[derive(Debug, Default)] +struct Hiding { + /// The ids of the loaded libraries the request may not read, longest first, each with the id + /// shown instead ([`unreadable_library_ids`]). + hidden: Vec<(String, String)>, + /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or + /// path that continues into one of them stays ([`known_continuation`]). + readable: std::collections::BTreeSet<String>, +} + +impl Hiding { + /// Adds the hidden and readable ids of `other` that this one does not hold. + fn merge(&mut self, other: Hiding) { + self.add_hidden(other.hidden); + self.readable.extend(other.readable); + } + + /// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown. + fn add_hidden(&mut self, pairs: Vec<(String, String)>) { + for pair in pairs { + if !self.hidden.iter().any(|(id, _)| *id == pair.0) { + self.hidden.push(pair); + } + } + self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + } + + /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for + /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { + let key = |id: &str| { + let id = id.replace('\\', "/"); + if fold { id.to_ascii_lowercase() } else { id } + }; + let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); + self.hidden.retain(|(id, _)| !sent.contains(&key(id))); + self.add_hidden(pairs); + } +} + +/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths +/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>` +/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file +/// name also for a hidden library shown as `loaded/<file name> (2)`, so that it reads the same whether +/// or not a library was loaded from one of them. +fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { + fn add(s: &str, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) { + return; + } + let path = match file_url_path(s) { + Some(Some(path)) => path, + Some(None) => return, + None => s.to_string(), + }; + let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let pair = (format!("loaded/{path}"), format!("loaded/{name}")); + if !out.contains(&pair) { + out.push(pair); + } + } + match v { + Value::String(s) => add(s, out), + Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, out); + sent_paths(item, out); + } + } + _ => {} + } } /// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`). @@ -411,15 +506,20 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id /// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's /// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the request may read the longer path -/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. -fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - replace_in_value(v, &hidden_text(ids)); +/// ([`continues_path`]) stays only when the longer text is another loaded library's id +/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// Windows the ids and paths are matched in any case of ASCII letters. +fn hide_library_ids(v: &mut Value, hiding: &Hiding) { + let pairs = hidden_text(&hiding.hidden); + if pairs.is_empty() { + return; + } + replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each -/// `\` doubled (a path quoted with `{:?}`). +/// path in it with the file name shown, the path written with either separator too, and with each `\` +/// doubled (a path quoted with `{:?}`). fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { let mut pairs: Vec<(String, String)> = Vec::new(); for (id, shown) in ids { @@ -438,49 +538,174 @@ fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { pairs } -/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each -/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the -/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` -/// (`dataBase64`, `pngBase64`), stays as it is. -fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { - if pairs.is_empty() { - return; - } +/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match +/// in any case of ASCII letters. +struct Rewrite<'a> { + pairs: Vec<(String, String)>, + hiding: &'a Hiding, + fold: bool, +} + +/// The keys of encoded file data in a reply. +const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; + +/// Is `s` written in the base64 alphabet, padded to a multiple of 4? +fn is_base64(s: &str) -> bool { + let body = s.trim_end_matches('='); + s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') +} + +/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as +/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays +/// as it is. +fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { Value::String(s) => { - if let Some(replaced) = replace_text(s, pairs) { + if let Some(replaced) = replace_text(s, r) { *s = replaced; } } - Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { for (key, mut item) in std::mem::take(fields) { - if key.ends_with("Base64") { + if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { fields.insert(key, item); continue; } - replace_in_value(&mut item, pairs); - fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + replace_in_value(&mut item, r); + fields.insert(replace_text(&key, r).unwrap_or(key), item); } } _ => {} } } -/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes -/// them. `None` when nothing changed. -fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> { +/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters +/// with `fold`. +fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { + if needle.is_empty() { + return Vec::new(); + } + if fold { + let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase()); + text.match_indices(needle.as_str()).map(|(at, _)| at).collect() + } else { + text.match_indices(needle).map(|(at, _)| at).collect() + } +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence +/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id +/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// replaced too; the escapes around it stay. `None` when nothing changed. +fn replace_text(s: &str, r: &Rewrite) -> Option<String> { + let replaced = replace_plain_text(s, r); + let text = replaced.as_deref().unwrap_or(s); + if !text.contains('%') { + return replaced; + } + let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; + if decoded == text { + return replaced; + } + // The spans of `text` to replace, found in the decoded text, longest `from` first. + let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); + let mut taken: Vec<(usize, usize)> = Vec::new(); + for (from, to) in &r.pairs { + for at in occurrences(&decoded, from, r.fold) { + let end = at + from.len(); + let free = !taken.iter().any(|&(a, b)| at < b && a < end); + if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + taken.push((at, end)); + cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + } + } + } + if cuts.is_empty() { + return replaced; + } + cuts.sort_unstable_by_key(|&(start, _, _)| start); + let mut out = String::with_capacity(text.len()); + let mut copied = 0; + for (start, stop, to) in cuts { + out.push_str(&text[copied..start]); + out.push_str(to); + copied = stop; + } + out.push_str(&text[copied..]); + Some(out) +} + +/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` +/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. +fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut spans = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = bytes + .get(i + 1..i + 3) + .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) => { + out.push(v); + spans.push((i, i + 3)); + i += 3; + } + None => { + out.push(bytes[i]); + spans.push((i, i + 1)); + i += 1; + } + } + } + let mut decoded = String::with_capacity(out.len()); + let mut decoded_spans = Vec::with_capacity(spans.len()); + let mut at = 0; + for chunk in out.utf8_chunks() { + decoded.push_str(chunk.valid()); + decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?); + at += chunk.valid().len(); + for _ in chunk.invalid() { + decoded.push(char::REPLACEMENT_CHARACTER); + let span = *spans.get(at)?; + decoded_spans.extend([span; 3]); + at += 1; + } + } + Some((decoded, decoded_spans)) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] +/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written +/// are replaced first, so that two libraries whose paths differ only in letter case keep their own +/// ids, and then the ones written in another case. `None` when nothing changed. +fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> { + let exact = replace_matches(s, r, false); + if !r.fold { + return exact; + } + replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case +/// of ASCII letters with `fold`. `None` when nothing changed. +fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { let mut out: Option<String> = None; - for (from, to) in pairs { + for (from, to) in &r.pairs { let text = out.as_deref().unwrap_or(s); - if from.is_empty() || !text.contains(from.as_str()) { + let found = occurrences(text, from, fold); + if found.is_empty() { continue; } let mut next = String::with_capacity(text.len()); let mut copied = 0; - for (at, _) in text.match_indices(from.as_str()) { + for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -500,13 +725,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, -/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden -/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. -fn readable_continuation(text: &str) -> bool { - let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); - let path = &text[..end]; - readable(path.strip_prefix("loaded/").unwrap_or(path)) +/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, +/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written +/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a +/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. +fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); + let longer = &text[at..stop]; + if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { + return false; + } + let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; + let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; + r.hiding.readable.iter().any(|known| same(known, &id)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -606,6 +837,19 @@ fn unreadable_absolute(s: &str) -> bool { } } +/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL +/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a +/// separator? It is checked as the file access check reads it, relative to the working folder. Other +/// text with a separator (`a/b`) stays. +fn unreadable_path_like(s: &str) -> bool { + if unreadable_absolute(s) { + return true; + } + let t = s.trim_start(); + let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p)); + relative && !readable_value(s) +} + /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { @@ -668,13 +912,16 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin /// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the /// paths the request may not read: the value of a path param /// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` -/// and the like), each path of a list under such a param, and under any other param an absolute path -/// or a `file:` URL ([`unreadable_absolute`]). +/// and the like), each path of a list under such a param, and under any other param, as a key too, a +/// string written as a path ([`unreadable_path_like`]). fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { use vectorcraft_engine::file_access::is_path_key; - params.retain(|key, v| match v.as_str() { - Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), - None => true, + params.retain(|key, v| { + !unreadable_path_like(key) + && match v.as_str() { + Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + } }); for (key, v) in params.iter_mut() { if let Value::Array(paths) = v @@ -686,13 +933,13 @@ fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { } } -/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL -/// the request may not read taken out of each list. +/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the +/// request may not read ([`unreadable_path_like`]) taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), Value::Array(items) => { - items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.retain(|item| !item.as_str().is_some_and(unreadable_path_like)); items.iter_mut().for_each(leave_out_unreadable_values); } _ => {} @@ -777,38 +1024,58 @@ const DOCKING_ACTION: &str = "action"; /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, -/// errors included: the libraries hidden after it ran, and before it for a request that names one. -/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. +/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The +/// error of a failed request also hides the paths outside the read root that the request sent +/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent +/// after the tool's edit) holds no library id or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + // Pointer, key and text input does not load, close or rename a library: the libraries hidden after + // it ran are the ones hidden before, so frequent input does not read the UI state twice. + let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) }; + let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); let mut params = req.params.clone(); - let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { - let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - dispatch(app, ctx, &request) - } else { - dispatch(app, ctx, req) + let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown); + let ran = vectorcraft_engine::guard::catch_panic(|| { + if swapped { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + } + }); + let outcome = match ran { + Ok(outcome) => outcome, + Err(msg) => { + app.picks.recover(); + app.session.set_replaying(false); + err(format!("internal error: {msg} (please report this bug)")) + } }; let Outcome::Done(mut reply) = outcome else { return outcome }; if !holds_a_separator(&reply) { return Outcome::Done(reply); } - let mut ids = unreadable_library_ids(app); - for pair in before { - if !ids.iter().any(|(id, _)| *id == pair.0) { - ids.push(pair); - } + let mut hiding = unreadable_library_ids(app); + hiding.merge(before); + if reply.get("ok").and_then(Value::as_bool) == Some(false) { + let mut sent = Vec::new(); + sent_paths(&req.params, &mut sent); + hiding.set_hidden(sent, cfg!(windows)); } - hide_library_ids(&mut reply, &ids); + hide_library_ids(&mut reply, &hiding); Outcome::Done(reply) } -/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +/// The control methods that send synthetic input. +const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"]; + +/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`? fn holds_a_separator(v: &Value) -> bool { - let has = |s: &str| s.contains(['/', '\\']); + let has = |s: &str| s.contains(['/', '\\', '%']); match v { Value::String(s) => has(s), Value::Array(items) => items.iter().any(holds_a_separator), @@ -886,6 +1153,18 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) hide_library_ids(&mut docking, &ids); ok(docking) } + // A request that stops with a panic quoting the status text, after clearing the Color Guide's + // limit with `forget`. + #[cfg(test)] + "test.panic" => { + if p.get("forget").and_then(Value::as_bool) == Some(true) { + app.ui.color_guide_limit.clear(); + } + panic!("failed on {}", app.ui.status) + } + // A request that succeeds with the status text. + #[cfg(test)] + "test.status" => ok(json!(app.ui.status)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 34b96b81e0..8407b04ffc 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -18,29 +18,125 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); let id = format!("loaded/{path}"); - let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let hidden = hiding(&[(&id, "loaded/b.svg")], &[]); let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); - let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(root); + hide_under_a_root("named-reply", &mut reply, &hidden); assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); } -/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. -#[test] -fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { - let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; - let data = "QUJDassets/iconREVG"; - let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); - let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); +/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids. +fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { + let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } +} + +/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. +fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { + let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); let _ = std::fs::remove_dir_all(root); - assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); +} + +/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or +/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 +/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); + let data = "QUJDassets/iconREVGxQQ=="; + let mut reply = json!({"ok": true, "result": { + "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], + "status": "from assets/icon", + "dataBase64": {"note": "from assets/icon"}, + "pngBase64": ["from assets/icon"], + "otherBase64": data, + }}); + hide_under_a_root("base64", &mut reply, &hidden); + let files = &reply["result"]["files"][0]; + assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); + assert_eq!(files["pngBase64"], "icon.png", "{reply}"); assert_eq!(reply["result"]["status"], "from icon", "{reply}"); + assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); + assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); +} + +/// A hidden path followed by more text stays only when the longer text is another loaded library's +/// id: text after the path that the request may read, a `..` part and the part of the path before a +/// space do not keep it. +#[test] +fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { + let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); + let other = base.join("outside").join("b.svg").to_string_lossy().into_owned(); + let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR); + let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]); + let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR); + let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(base); + assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); +} + +/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden +/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. +#[test] +fn a_reply_hides_a_path_in_any_case_and_escaped() { + let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); + let pairs = super::hidden_text(&hidden.hidden); + let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; + super::replace_in_value(&mut reply, &rewrite); + assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); + // Two libraries whose paths differ only in letter case keep their own ids. + let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); + let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); + hide_under_a_root("escaped", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///b.svg", "{reply}"); + let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); + hide_under_a_root("escaped-mixed", &mut reply, &hidden); + assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); +} + +/// A path a failed request sent takes the place of a hidden library's id for the same path, written +/// with the other separator, and in another letter case when matching in any case (as on Windows). +#[test] +fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { + let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); + hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); + let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); + assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); +} + +/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or +/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and +/// a path inside the root are not. +#[test] +fn a_request_sends_paths_that_an_error_hides() { + let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let inside = root.join("a.svg").to_string_lossy().into_owned(); + let keep = json!(["a/b", inside, "file://host/g.svg"]); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let mut sent = Vec::new(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); + let _ = std::fs::remove_dir_all(base); + let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); + assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..3b7f338dbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,6 +166,10 @@ pub struct VectorcraftApp { /// `ui.pointer` replies waiting for their events and the edit the tool computes on another /// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events. pointer_replies: Vec<control::PendingPointerReply>, + /// The id each library loaded from outside the automation read roots is shown under, kept while + /// the UI state or the session refers to the library, so that the id a control client was given + /// keeps naming the same library. + shown_library_ids: std::cell::RefCell<Vec<(String, String)>>, /// Admitted captures whose settled frame has not come yet (issued one at a time). queued_screenshots: Vec<QueuedScreenshot>, screenshot_token: u64, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 2db9feaec5..01929f578c 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -627,6 +627,118 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { let _ = std::fs::remove_dir_all(base); } +/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the +/// order of their paths. +#[test] +fn hidden_libraries_are_numbered_in_load_order() { + let (mut app, tx, base) = confined("hidden-load-order"); + let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; + for dir in ["z", "a"] { + std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); + let path = base.join("outside").join(dir).join("b.gpl"); + std::fs::write(&path, gpl).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); + } + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library keeps the id a reply showed for it when another library with the same file name is +/// loaded later. +#[test] +fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { + let (mut app, tx, base) = confined("hidden-stable-id"); + let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); + let path = base.join("outside").join("z").join("b.gpl"); + std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + app.ui.color_guide_limit = format!("loaded/{}", s(path)); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error from a panic hides a hidden library's path, also when the request stopped referring to +/// the library before it panicked. +#[test] +fn a_panic_reply_hides_a_library_hidden_before_the_request() { + let (mut app, tx, base) = confined("panic-hidden"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error shows a path outside the read root that the request sent by its file name, whether or not +/// a hidden library was loaded from that path, so that it reads the same either way. A path the +/// request sends does not show the app's own text that holds it, in an error or a result. +#[test] +fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { + let (mut app, tx, base) = confined("client-echo"); + let path = s(base.join("outside/b.svg")); + // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. + let first = format!("loaded/{}", s(base.join("outside/z/b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; + for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { + let r = request(&mut app, &tx, "app.open", json!({"path": sent})); + assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); + } + let r = request(&mut app, &tx, "test.status", json!({"guess": path})); + assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped +/// shows the file name. +#[test] +fn a_reply_hides_a_path_with_escaped_separators() { + let (mut app, tx, base) = confined("escaped-separators"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from file:b.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and +/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and +/// other text with a separator stay. +#[test] +fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-relative"); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], + ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); + assert_eq!(steps, &want, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -687,7 +799,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index 3deca5409a..05e2cab208 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -131,9 +131,10 @@ the suite) keep the agent to the folders you give it: documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, - `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an - absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names - another host is always left out); and the folder + `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any + list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that + names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` + (either separator) that resolves outside the roots from the app's working folder; and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -142,17 +143,25 @@ the suite) keep the agent to the folders you give it: params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform. When another hidden library has the same file name, the libraries take `loaded/<file name>`, - `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both - replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep - their order, and the app's own state is not changed. A request may name such a library or its panel by the id + `loaded/<file name> (2)`, `(3)` and so on: first the libraries loaded in the session (swatch, graphic style, + symbol and brush libraries, each kind in the order it was loaded), then the library panels in their order, the + Color Guide's limit and the other ids. Each one keeps its id in both replies, and from one request to the next + while the app refers to it, also when another library with the same file name is loaded later. A hidden library skips an id that + a library loaded without a path already has. The other entries keep their order, and the app's own state is not + changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one - keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one - stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. + (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the + libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path + keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable + library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it, so that the + error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + `pngBase64` or `thumbnailBase64`, is sent as it is. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From cce2a685bca1b75625d17cece5fa77f8eb3d15cc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:43:49 -0800 Subject: [PATCH 022/126] MCP: a failed request's error hides a file URL it sent that names another host The error shows the URL's file name, as it does for a local path the request sent outside the read roots. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4f66142e1c..64d9e7b061 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -450,9 +450,10 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { if !unreadable_path_like(s) { return; } + // A URL that names another host as the path after its scheme (`//host/share/…`). let path = match file_url_path(s) { Some(Some(path)) => path, - Some(None) => return, + Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 8407b04ffc..badf602cc9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -120,9 +120,9 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); } -/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or -/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and -/// a path inside the root are not. +/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names +/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a +/// path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -130,13 +130,20 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); - assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); + let want = [ + pair("/y/c d.svg", "c d.svg"), + pair("~/e.svg", "e.svg"), + pair("../f.svg", "f.svg"), + pair("//host/g.svg", "g.svg"), + pair("/x/my lib/b.svg", "b.svg"), + ]; + assert_eq!(sent, want); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a From 027035668b78174384bfeda7689516dcf2b86673 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:07:30 -0800 Subject: [PATCH 023/126] MCP: a readable library id keeps its text when a space follows the hidden part A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, also when that id has a space after the hidden part. --- crates/ui-egui/src/control.rs | 41 ++++++++++++++--------------- crates/ui-egui/src/control/tests.rs | 6 +++++ docs/mcp.md | 5 ++-- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 64d9e7b061..29c9fbbb5c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -406,7 +406,7 @@ struct Hiding { /// shown instead ([`unreadable_library_ids`]). hidden: Vec<(String, String)>, /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or - /// path that continues into one of them stays ([`known_continuation`]). + /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet<String>, } @@ -506,9 +506,8 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name /// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id /// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's -/// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the longer text is another loaded library's id -/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// report and a recorded step. An occurrence that begins another loaded library's id stays +/// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(&hiding.hidden); @@ -596,8 +595,8 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { } /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence -/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id -/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written +/// with `%XX` escapes (in a `file:` URL) is /// replaced too; the escapes around it stay. `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { let replaced = replace_plain_text(s, r); @@ -616,7 +615,7 @@ fn replace_text(s: &str, r: &Rewrite) -> Option<String> { for at in occurrences(&decoded, from, r.fold) { let end = at + from.len(); let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + if free && !begins_known_id(&decoded, at, end, r) { taken.push((at, end)); cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); } @@ -706,7 +705,7 @@ fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { let mut copied = 0; for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { + if !begins_known_id(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -726,19 +725,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, -/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written -/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a -/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. -fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { - let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); - let longer = &text[at..stop]; - if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { - return false; - } - let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; - let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; - r.hiding.readable.iter().any(|known| same(known, &id)) +/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written +/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops +/// continuing a path ([`continues_path`])? +/// A readable library's id that begins with a hidden one stays, also with a space after the hidden +/// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let rest = &text[at..]; + let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; + let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + }) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index badf602cc9..800a57c795 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -82,6 +82,12 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); let _ = std::fs::remove_dir_all(base); assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); + // A readable library's id with a space after the hidden part stays whole. + // A readable id with a `..` part does not keep the hidden part. + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]); + let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"}); + hide_under_a_root("space-continuation", &mut reply, &hidden); + assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}"); } /// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden diff --git a/docs/mcp.md b/docs/mcp.md index 05e2cab208..938833d02d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,9 +156,8 @@ the suite) keep the agent to the folders you give it: path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path - keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable - library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the error reads the same either way. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. From 2d2e8fa53fc2cb22a8aeacc569a7f8fcd2218430 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:22:51 -0800 Subject: [PATCH 024/126] MCP: a screenshot's reply hides library paths outside the read roots The reply is sent after the request was handled, and now hides what any other reply hides. --- crates/ui-egui/src/app/screenshot.rs | 9 ++++--- crates/ui-egui/src/control.rs | 34 +++++++++++++++++++------- crates/ui-egui/src/tests/automation.rs | 20 +++++++++++++++ docs/mcp.md | 4 +-- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index ce97fc747f..c34d022e50 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -157,9 +157,12 @@ impl VectorcraftApp { let pending = self.pending_screenshots.remove(index); self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); let roots = self.automation_roots.clone(); - let _ = pending - .reply - .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); + let reply = file_access::confine(roots.as_ref(), || { + let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data); + control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path})); + reply + }); + let _ = pending.reply.send(reply); } Err(e) => { let error = format!("the screenshot could not be used: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 29c9fbbb5c..066d3d82db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1027,8 +1027,9 @@ const DOCKING_ACTION: &str = "action"; /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The /// error of a failed request also hides the paths outside the read root that the request sent /// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path -/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent -/// after the tool's edit) holds no library id or path. +/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent +/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id +/// or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); @@ -1056,18 +1057,33 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } }; let Outcome::Done(mut reply) = outcome else { return outcome }; - if !holds_a_separator(&reply) { - return Outcome::Done(reply); + hide_in_reply(app, &mut reply, &req.params, before); + Outcome::Done(reply) +} + +/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths +/// outside the read root that `sent`, the request's params, holds ([`handle`]). +fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { + if !holds_a_separator(reply) { + return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut sent = Vec::new(); - sent_paths(&req.params, &mut sent); - hiding.set_hidden(sent, cfg!(windows)); + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); + } + hide_library_ids(reply, &hiding); +} + +/// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a +/// reply, with `sent` as the request's params. Without a read root in force it leaves the reply as +/// it is. +pub(crate) fn hide_in_deferred_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value) { + if vectorcraft_engine::file_access::current().is_some() { + hide_in_reply(app, reply, sent, Hiding::default()); } - hide_library_ids(&mut reply, &hiding); - Outcome::Done(reply) } /// The control methods that send synthetic input. diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 01929f578c..05811869ae 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -704,6 +704,26 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let _ = std::fs::remove_dir_all(base); } +/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an +/// error the path outside the read root that the request sent. +#[test] +fn a_screenshot_reply_hides_paths_like_any_reply() { + let (mut app, _tx, base) = confined("deferred-reply"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + let sent = s(base.join("outside/shot.png")); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); + let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); + crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); + }); + assert_eq!(done["result"]["note"], "from b.svg", "{done}"); + assert_eq!(failed["error"], "could not write shot.png", "{failed}"); + let _ = std::fs::remove_dir_all(base); +} + /// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped /// shows the file name. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 938833d02d..278d69e8d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -154,8 +154,8 @@ the suite) keep the agent to the folders you give it: reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the - libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal + fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the From 45987bc9484e4bbd194716f486bd12f1f5b7e5ea Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:25:19 -0800 Subject: [PATCH 025/126] Plug-ins: a grant one session stops counting stops counting for every session on its folder The grants that should be gone from grants.json but could not be removed were kept per Session, so a second Session attached to the same Plug-ins folder in the same process still counted them for plugin.inspect, plugin.install and loading plug-ins. They are now kept for the process, by the folder's canonical path and the plug-in id, and a successful write of grants.json for the id in any session makes the grant count again. Attaching another folder no longer clears them. A failed removal does not override a write another session made for the id in the meantime. The process keeps the latest 256 grant writes for that check. Another process on the folder still reads grants.json as it is written. --- crates/engine/src/cmd/plugin.rs | 10 +- crates/engine/src/lib.rs | 4 - crates/engine/src/session/documents.rs | 1 - crates/engine/src/store.rs | 123 +++++++++++++--- crates/engine/src/tests/plugins.rs | 188 ++++++++++++++++++++++++- docs/plugins.md | 12 +- docs/target-app-parity.md | 1 + 7 files changed, 302 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index a5b71d538b..110672da21 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -759,14 +759,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> { // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there - // was none, no module goes with the grant, and the grant stops counting for this session. A - // grants file that is too large or doesn't hold a list approves nothing, and the new module - // stays; one that can't be opened or read may approve the old bytes once it reads again. + // was none, no module goes with the grant, and the grant stops counting for every session in + // the process attached to the folder. A grants file that is too large or doesn't hold a list + // approves nothing, and the new module stays; one that can't be opened or read may approve + // the old bytes once it reads again. + let writes = s.grant_writes(); if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); stored = !back; if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&m.id); + s.ignore_stored_grant(&m.id, writes); } } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..7d71e8bfea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,10 +171,6 @@ pub struct Session { /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder /// ([`Session::attach_scripts_dir`]). scripts_dir: Option<std::path::PathBuf>, - /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: - /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful - /// [`Session::store_grant`] for the id takes it out. - ignored_plugin_grants: std::sync::Mutex<std::collections::BTreeSet<String>>, /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option<cmd::plugin::HandedIn>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 63fbcd5a78..3f45e8aeab 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -87,7 +87,6 @@ impl Session { color_settings_file: None, plugin_dir: None, scripts_dir: None, - ignored_plugin_grants: Default::default(), plugin_output: None, plugin_install_approval: None, plugin_grants_from_requests: false, diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 01ebc46526..24d04230a1 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -88,6 +88,24 @@ static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); /// The state of each plug-in folder's storage, shared by every storage made for the folder until /// the plug-in is removed, which closes it and drops it. static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<PluginData>>> = Mutex::new(BTreeMap::new()); +/// The grants of plug-in stores that the sessions of the process don't count, and when each was +/// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is. +static IGNORED_PLUGIN_GRANTS: Mutex<IgnoredGrants> = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 }); +/// How many of the latest grant writes [`IgnoredGrants`] keeps. +const MAX_GRANT_WRITES_KEPT: usize = 256; + +/// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id. +struct IgnoredGrants { + /// The grants that should be gone but could not be removed: no session in the process counts + /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id + /// ([`Session::store_grant`]). + ignored: BTreeSet<(PathBuf, String)>, + /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the + /// count of writes it made ([`IgnoredGrants::writes`]). + written: BTreeMap<(PathBuf, String), u64>, + /// How many grant writes the sessions of the process have made ([`Session::grant_writes`]). + writes: u64, +} /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -99,6 +117,37 @@ pub(crate) fn file_stem(name: &str) -> String { format!("{}-{hash:016x}", safe.trim()) } +/// The folder `dir` spelled one way for every path to it, so that sessions attached to it by +/// different paths share [`IGNORED_PLUGIN_GRANTS`]: the canonical path when the folder exists, else +/// the canonical path of its nearest folder that exists followed by the rest of the path, with `.` +/// and `..` taken out by name. The path as given when it has no absolute form (the current folder +/// can't be read). +pub(crate) fn plugin_store_key(dir: &Path) -> PathBuf { + if let Ok(path) = std::fs::canonicalize(dir) { + return path; + } + let Ok(absolute) = std::path::absolute(dir) else { return dir.to_path_buf() }; + let mut parts: Vec<std::path::Component> = Vec::new(); + for part in absolute.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!(parts.last(), Some(std::path::Component::Normal(_))) { + parts.pop(); + } + } + _ => parts.push(part), + } + } + for kept in (1..=parts.len()).rev() { + let base: PathBuf = parts[..kept].iter().collect(); + if let Ok(found) = std::fs::canonicalize(&base) { + return parts[kept..].iter().fold(found, |path, part| path.join(part)); + } + } + parts.iter().collect() +} + /// The regular files of `dir` with the extension `ext` (any case), by file name, at most /// [`MAX_STORE_FILES`]; none when the folder can't be read. fn list_files(dir: &Path, ext: &str) -> Vec<PathBuf> { @@ -334,9 +383,6 @@ impl Session { let disabled = read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); safe_mode::set_disabled(disabled); - if self.plugin_dir.as_ref() != Some(&dir) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).clear(); - } self.plugin_dir = Some(dir); self.load_stored_plugins(); } @@ -347,7 +393,8 @@ impl Session { /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and /// stays in the folder with its grant. A grant for an id whose stored module lists no /// permissions is forgotten; one that can't be removed from the grants file doesn't count for - /// the rest of the session ([`Session::plugin_approver`]). + /// any session in the process attached to the folder until the file can be written for the id + /// ([`Session::ignore_stored_grant`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -407,8 +454,11 @@ impl Session { Ok(file) => file.names(id), Err(e) => e.unread, }; - if named && !self.store_grant(id, None) && self.holds_plugin_grant(id) { - self.ignore_stored_grant(id); + if named { + let writes = self.grant_writes(); + if !self.store_grant(id, None) && self.holds_plugin_grant(id) { + self.ignore_stored_grant(id, writes); + } } } } @@ -469,12 +519,16 @@ impl Session { } } - /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session - /// doesn't count ([`Session::ignore_stored_grant`]). + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process + /// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked + /// out on each call, so a path whose link now leads elsewhere is matched with where it leads. fn counted_grants(&self, dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permissions::Grant> { - let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()); + let key = plugin_store_key(dir); let mut grants = stored_grants(dir); - grants.retain(|id, _| !ignored.contains(id)); + let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) { + grants.remove(id); + } grants } @@ -522,20 +576,53 @@ impl Session { /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now /// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts - /// again ([`Session::ignore_stored_grant`]). + /// again, for every session in the process attached to the store ([`Session::ignore_stored_grant`]). pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool { let done = self.write_grant(id, grant); - if done { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id); + if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + shared.ignored.remove(&entry); + shared.writes = shared.writes.saturating_add(1); + let writes = shared.writes; + shared.written.insert(entry, writes); + if shared.written.len() > MAX_GRANT_WRITES_KEPT + && let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone()) + { + shared.written.remove(&oldest); + } } done } - /// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next - /// succeeds for it or another store is attached: for a grant that should be gone and could not - /// be removed, which would otherwise approve the bytes it covers. - pub(crate) fn ignore_stored_grant(&self, id: &str) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string()); + /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). + /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. + pub(crate) fn grant_writes(&self) -> u64 { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes + } + + /// How many grant writes the process keeps ([`IgnoredGrants::written`]). + #[cfg(test)] + pub(crate) fn grant_writes_kept() -> usize { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len() + } + + /// Stop counting the attached store's grant for the plug-in `id`, in every session in the + /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in + /// one of them: for a grant that should be gone and could not be removed, which would otherwise + /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed + /// write: when a session has written the store's grants for `id` since, the file holds what that + /// write asked for, and the grant keeps counting. A write no longer among the latest + /// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the + /// folder reads `grants.json` as it is. + pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) { + if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + if shared.written.get(&entry).is_none_or(|at| *at <= writes) { + shared.ignored.insert(entry); + } + } } fn write_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a4be6e554f..e73fee06d6 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1816,11 +1816,11 @@ fn a_grant_that_cannot_be_removed_and_has_no_stored_module_stops_counting() { run(&mut s, "plugin.remove", json!({"id": id})); } -/// A grant that a load of the plug-in store should forget and can't stays out of the approvals for -/// the session: both when `grants.json` can't be rewritten and when it can't be read at the load. -/// A grant recorded later counts again, and another store starts with nothing ignored. +/// A grant that a load of the plug-in store should forget and can't stays out of the approvals in +/// the process: both when `grants.json` can't be rewritten and when it can't be read at the load. +/// A grant recorded later counts again, and another store's grant for the id counts. #[test] -fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { +fn a_grant_the_load_cannot_remove_stops_counting_in_the_process() { use vectorcraft_plugins::permissions::sha256_hex; let folder = crate::tests_links::Folder::new("plugin-store-load-forget"); let elsewhere = crate::tests_links::Folder::new("plugin-store-load-forget-other"); @@ -1852,8 +1852,12 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { next.attach_plugin_store(elsewhere.0.clone()); assert_eq!(run(&mut next, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + // A load that can rewrite the file forgets the grant, and the store has nothing ignored again. + std::fs::write(&grants, &recorded).unwrap(); + next.attach_plugin_store(folder.0.clone()); + assert!(!grants.exists(), "the load removes the grant"); + // A directory at the file's path gives a read error on every platform. - std::fs::remove_file(&grants).unwrap(); std::fs::create_dir(&grants).unwrap(); vectorcraft_plugins::registry::remove(id); let mut third = session(); @@ -1879,6 +1883,180 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { run(&mut third, "plugin.remove", json!({"id": id})); } +/// A grant one session stops counting stops counting for every session in the process attached to +/// the same folder, by any path to it: one attached before, one attached after with no stored module +/// to try the removal for, and one that comes back from another folder. A successful write of the +/// grant in any of them makes it count in all of them again. +#[test] +fn an_ignored_stored_grant_is_shared_by_sessions_on_the_same_folder() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-shared-ignore"); + let elsewhere = crate::tests_links::Folder::new("plugin-store-shared-ignore-other"); + let id = "test.engine.permissions.sharedignore"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let needs_approval = json!({"dataBase64": approved_data}); + let grants = folder.0.join("grants.json"); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + let mut first = session(); + first.attach_plugin_store(folder.0.clone()); + let inside = folder.0.join("inside"); + std::fs::create_dir(&inside).unwrap(); + let mut other = session(); + other.attach_plugin_store(inside.join("..")); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + // `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read. + let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes(); + std::fs::write(&grants, &full).unwrap(); + run(&mut first, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)})); + let file = first.stored_plugin_file(id).unwrap(); + assert!(!std::path::Path::new(&file).exists(), "the store keeps no module"); + assert_eq!(std::fs::read(&grants).unwrap(), full); + + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + let refused = other.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string(); + assert!(refused.contains("doc.read"), "{refused}"); + std::fs::write(&file, &approved).unwrap(); + vectorcraft_plugins::registry::remove(id); + other.load_stored_plugins(); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "the formerly approved module isn't loaded"); + std::fs::remove_file(&file).unwrap(); + + let mut later = session(); + later.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut later, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "a session attached later doesn't count it"); + + std::fs::write(elsewhere.0.join("grants.json"), &recorded).unwrap(); + other.attach_plugin_store(elsewhere.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + other.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "back on the folder, it doesn't"); + + std::fs::write(&grants, &recorded).unwrap(); + assert!(later.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + for s in [&mut first, &mut other, &mut later] { + assert_eq!(run(s, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + } + std::fs::remove_file(&grants).unwrap(); +} + +/// A plug-in folder that doesn't exist yet gets the key it gets once it is made, and every path to a +/// folder gets the same key. +#[test] +fn a_plugin_folder_has_one_key_for_every_path_to_it() { + use crate::store::plugin_store_key; + let folder = crate::tests_links::Folder::new("plugin-store-key"); + let dir = folder.0.join("Plug-ins"); + let before = plugin_store_key(&dir); + assert_eq!(plugin_store_key(&dir.join("sub").join("..")), before); + assert_eq!(plugin_store_key(&folder.0.join(".").join("Plug-ins")), before); + std::fs::create_dir(&dir).unwrap(); + assert_eq!(plugin_store_key(&dir), before, "the key stays when the folder is made"); + assert_eq!(plugin_store_key(&dir.join(".")), before); + #[cfg(windows)] + { + let plain = std::path::absolute(&dir).unwrap(); + let verbatim = std::path::PathBuf::from(format!(r"\\?\{}", plain.display())); + assert_eq!(plugin_store_key(&verbatim), before, "a verbatim path names the same folder"); + } +} + +/// Sessions attached to a plug-in folder before it exists, by paths that only name the same folder +/// once it is made, share the grants the process doesn't count for it. A grant that one session +/// writes while another fails to remove it keeps counting. +#[test] +fn sessions_attached_before_the_folder_exists_share_its_ignored_grants() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-late-folder"); + let id = "test.engine.permissions.latefolder"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + std::fs::create_dir_all(folder.0.join("a").join("b")).unwrap(); + let dir = folder.0.join("a").join("Plug-ins"); + // On disk `link/..` is `a`, so `link/../Plug-ins` is the folder; by name it is a `Plug-ins` beside `a`. + // On Windows the folder is named in other letter case. + #[cfg(unix)] + let alias = { + std::os::unix::fs::symlink(folder.0.join("a").join("b"), folder.0.join("link")).unwrap(); + folder.0.join("link").join("..").join("Plug-ins") + }; + #[cfg(windows)] + let alias = folder.0.join("A").join("PLUG-INS"); + #[cfg(not(any(unix, windows)))] + let alias = dir.clone(); + let mut first = session(); + first.attach_plugin_store(dir.clone()); + let mut other = session(); + other.attach_plugin_store(alias); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + first.ignore_stored_grant(id, first.grant_writes()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + assert!(other.store_grant(id, Some(serde_json::from_value(entry.clone()).unwrap())), "the file can be written"); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + + // A write that fails in one session while another writes the grant for the id leaves the grant + // counting. + let before = first.grant_writes(); + assert!(other.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the other session writes the grant"); + first.ignore_stored_grant(id, before); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval)["needsApproval"], false, "the written grant counts"); + std::fs::remove_file(dir.join("grants.json")).unwrap(); +} + +/// Grant writes that change nothing, each for another id, don't grow what the process keeps past its +/// limit. +#[test] +fn the_process_keeps_a_bounded_number_of_grant_writes() { + let folder = crate::tests_links::Folder::new("plugin-store-writes-kept"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + for n in 0..300 { + assert!(s.store_grant(&format!("test.engine.permissions.unknown{n}"), None), "nothing to remove is a success"); + } + assert!(Session::grant_writes_kept() <= 256, "{}", Session::grant_writes_kept()); +} + +/// A session attached by a link to a plug-in folder matches the grants the process doesn't count +/// with the folder the link leads to now, not the one it led to when the session was attached. +#[cfg(unix)] +#[test] +fn a_linked_plugin_folder_uses_the_ignored_grants_of_where_the_link_leads() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-relinked"); + let id = "test.engine.permissions.relinked"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + let (old, new, link) = (folder.0.join("old"), folder.0.join("new"), folder.0.join("Plug-ins")); + for dir in [&old, &new] { + std::fs::create_dir(dir).unwrap(); + std::fs::write(dir.join("grants.json"), &recorded).unwrap(); + } + std::os::unix::fs::symlink(&old, &link).unwrap(); + let mut linked = session(); + linked.attach_plugin_store(link.clone()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&new, &link).unwrap(); + let mut direct = session(); + direct.attach_plugin_store(new.clone()); + direct.ignore_stored_grant(id, direct.grant_writes()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the link leads to the ignored grant"); + assert!(linked.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + assert_eq!(run(&mut direct, "plugin.inspect", needs_approval)["needsApproval"], false, "it counts again"); +} + /// A grants file that can't be opened or read may name the old module once it reads again, so /// neither a module listing no permissions nor `plugin.remove` gets past it. A directory at the /// file's path gives a read error on every platform. diff --git a/docs/plugins.md b/docs/plugins.md index 06fd89f28a..c21e677083 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -248,14 +248,16 @@ capability model proposed for PhotoCraft plug-ins write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be removed and the module already stored can't be read back or written back, the new module stays in the folder, and - the grant does not count for the rest of the session. The grant also does not count when the folder held no module - for that id. A + the grant stops counting. The grant also stops counting when the folder held no module for that id. A `grants.json` larger than 1 MiB, or one that is not a JSON list, grants nothing: the new module stays in the folder, and `plugin.remove` removes the plug-in. A `grants.json` that can't be opened or read counts as a grant that can't be removed. When VectorCraft loads the `Plug-ins` folder, it forgets any grant for an id whose stored module lists no - permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant does not count for - the rest of the session, or until the plug-in is granted again, including for the next load, which tries the removal - again. In Safe Mode, and for + permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant stops counting, + including for the next load, which tries the removal again. A grant that stops counting in either of these cases + does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until + `grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same + folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the + same id kept in another `Plug-ins` folder counts. In Safe Mode, and for a stored module that the user disabled, VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..9902401856 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1659,6 +1659,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. +- **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. From 7f5e4519a5a825ae1fcb0988580a242810c2f4ff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:26:09 -0800 Subject: [PATCH 026/126] Help: input in a focused floating window is timed for the hang report The end of a frame counted the renderer as busy only when the main window had the focus, so a stall drawing input in a focused floating panel or document window wrote no hang report. A floating window that has the focus and is not minimized now counts too. A frame that sends that window commands to minimize or hide it, or to move or resize it, ends idle, including commands its own pass sent, which are noted before egui passes them on. --- apps/vectorcraft/src/app.rs | 12 +++- crates/ui-egui/src/hang_beacon.rs | 113 +++++++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index bfd8ce1c18..288fa6cdc3 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -128,10 +128,18 @@ impl App { BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len()); } // Hide on macOS hides the window at once, before the window reports losing focus. - let shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) + let main_shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) && self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false)); + // A floating panel or document window with the focus counts as well, with the commands it + // was sent after its pass, and those its pass sent (`hang_beacon::watch_input`). + let shown = ctx.input(|i| hang_beacon::shown_window(main_shown, &i.raw.viewports)); + let handed_off = BEACON.take_handed_off(); let input = BEACON.take_input(); - BEACON.enter(ctx.viewport(|vp| hang_beacon::after_frame(input, shown, &vp.commands))); + let stage = match shown { + Some(id) => ctx.viewport_for(id, |vp| hang_beacon::after_frame(input && !handed_off.contains(&id), true, &vp.commands)), + None => hang_beacon::after_frame(input, false, &[]), + }; + BEACON.enter(stage); } fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs index c681bc8131..3331a1e39c 100644 --- a/crates/ui-egui/src/hang_beacon.rs +++ b/crates/ui-egui/src/hang_beacon.rs @@ -159,6 +159,9 @@ pub struct Beacon { documents: AtomicUsize, /// A pass of some window had input since [`Beacon::take_input`] last ran. input: AtomicBool, + /// The windows a pass handed to the system since [`Beacon::take_handed_off`] last ran. Only the + /// interface thread uses it. + handed_off: Mutex<Vec<egui::ViewportId>>, commands: Mutex<VecDeque<String>>, frames: Mutex<VecDeque<FrameTime>>, } @@ -177,6 +180,7 @@ impl Beacon { activity: AtomicU8::new(Activity::None as u8), documents: AtomicUsize::new(0), input: AtomicBool::new(false), + handed_off: Mutex::new(Vec::new()), commands: Mutex::new(VecDeque::new()), frames: Mutex::new(VecDeque::new()), } @@ -234,6 +238,20 @@ impl Beacon { self.input.swap(false, Ordering::Relaxed) } + /// A pass of window `id` sent it commands that hand it to the system ([`hands_off`], + /// [`watch_input`]). + pub fn hand_off(&self, id: egui::ViewportId) { + let mut windows = self.handed_off.lock().unwrap_or_else(PoisonError::into_inner); + if !windows.contains(&id) { + windows.push(id); + } + } + + /// The windows a pass handed to the system since the last call. + pub fn take_handed_off(&self) -> Vec<egui::ViewportId> { + std::mem::take(&mut *self.handed_off.lock().unwrap_or_else(PoisonError::into_inner)) + } + /// A frame ended with `time`, with `documents` documents open. pub fn frame(&self, time: FrameTime, documents: usize) { self.documents.store(documents, Ordering::Relaxed); @@ -285,7 +303,9 @@ pub fn had_input(raw: &egui::RawInput) -> bool { /// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main /// window's, after the host's input hook added its own, and those of the windows shown in line -/// (floating panels and documents), whose input does not pass through that hook. +/// (floating panels and documents), whose input does not pass through that hook. Mark as well every +/// pass of a window shown in line that sends its window commands that hand it to the system +/// ([`hands_off`]), before egui passes them on; the main window's are read at the frame's end. pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { ctx.on_begin_pass( "VectorCraft hang report input", @@ -295,17 +315,39 @@ pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { } }), ); + ctx.on_end_pass( + "VectorCraft hang report hand-off", + std::sync::Arc::new(move |ui| { + if ui.ctx().viewport_id() != egui::ViewportId::ROOT && ui.ctx().viewport(|vp| hands_off(&vp.commands)) { + beacon.hand_off(ui.ctx().viewport_id()); + } + }), + ); } -/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and the main -/// window is `shown` (focused, not minimized or hidden), since input asks for the next frame at -/// once; otherwise [`Stage::Idle`]. A frame that sends the main window `commands` that minimize or -/// hide it, or hand it to the system to move or resize, also ends idle: the system may hold back -/// the next frame until the user is done. -pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { +/// Whether `commands` minimize or hide their window, or hand it to the system to move or resize: +/// the system may hold back the next frame until the user is done. +pub fn hands_off(commands: &[egui::ViewportCommand]) -> bool { use egui::ViewportCommand as C; - let handed_off = commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))); - if input && shown && !handed_off { Stage::Drawing } else { Stage::Idle } + commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))) +} + +/// The window whose end of frame sets the stage ([`after_frame`]): the main window when it is +/// `main_shown`; otherwise a window shown in line (a floating panel or document) that `viewports` +/// (the main window's input) gives as focused and not minimized; otherwise none. +pub fn shown_window(main_shown: bool, viewports: &egui::ViewportIdMap<egui::ViewportInfo>) -> Option<egui::ViewportId> { + if main_shown { + return Some(egui::ViewportId::ROOT); + } + viewports.iter().find(|(id, v)| **id != egui::ViewportId::ROOT && v.focused == Some(true) && v.minimized != Some(true)).map(|(id, _)| *id) +} + +/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and a window is +/// `shown` (focused, not minimized or hidden: [`shown_window`]), since input asks for the next +/// frame at once; otherwise [`Stage::Idle`]. A frame that sends that window `commands` that +/// minimize or hide it, or hand it to the system to move or resize ([`hands_off`]), also ends idle. +pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { + if input && shown && !hands_off(commands) { Stage::Drawing } else { Stage::Idle } } /// Ends an [`Activity`] when dropped ([`Beacon::activity`]). @@ -496,6 +538,59 @@ mod tests { assert!(B.take_input(), "the main window's input"); } + #[test] + fn a_window_shown_in_line_handed_to_the_system_is_marked() { + static B: Beacon = Beacon::new(); + egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| { + let id = viewport.ids.this; + let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect(); + let input = egui::RawInput { viewport_id: id, viewports, ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + watch_input(&B, &ctx); + let child = egui::ViewportId::from_hash_of("floating panel"); + let frame = |command: Option<egui::ViewportCommand>, main: Option<egui::ViewportCommand>| { + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |ui, _| { + if let Some(c) = command.clone() { + ui.ctx().send_viewport_cmd(c); + } + }); + if let Some(c) = main.clone() { + ui.ctx().send_viewport_cmd(c); + } + }) + .textures_delta + .clear(); + }; + frame(None, None); + assert!(B.take_handed_off().is_empty(), "nothing handed off"); + frame(Some(egui::ViewportCommand::StartDrag), None); + assert_eq!(B.take_handed_off(), [child], "the floating window starts a move"); + assert!(B.take_handed_off().is_empty(), "taken"); + frame(Some(egui::ViewportCommand::Focus), None); + assert!(B.take_handed_off().is_empty(), "another command"); + frame(None, Some(egui::ViewportCommand::StartDrag)); + assert!(B.take_handed_off().is_empty(), "the main window's commands are read at the frame's end"); + } + + #[test] + fn a_focused_window_shown_in_line_is_the_shown_window() { + use egui::{ViewportId, ViewportIdMap, ViewportInfo}; + let child = ViewportId::from_hash_of("document window"); + let info = |focused: bool, minimized: bool| ViewportInfo { focused: Some(focused), minimized: Some(minimized), ..Default::default() }; + let map = |root: ViewportInfo, window: ViewportInfo| -> ViewportIdMap<ViewportInfo> { + [(ViewportId::ROOT, root), (child, window)].into_iter().collect() + }; + assert_eq!(shown_window(true, &map(info(true, false), info(false, false))), Some(ViewportId::ROOT)); + assert_eq!(shown_window(false, &map(info(false, false), info(true, false))), Some(child), "the document window has the focus"); + assert_eq!(shown_window(false, &map(info(false, false), info(true, true))), None, "minimized"); + assert_eq!(shown_window(false, &map(info(false, false), info(false, false))), None, "no window has the focus"); + assert_eq!(shown_window(false, &map(info(true, false), info(false, false))), None, "the main window is not shown"); + } + #[test] fn the_marked_clipboard_marks_its_calls() { type Seen = std::sync::Arc<Mutex<Vec<Activity>>>; From 386c444ff47c11d44c3bf57900ade8de09250d96 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:31:25 -0800 Subject: [PATCH 027/126] MCP: a readable library id written with LOADED/ keeps its text on Windows The loaded/ prefix is matched in any case where the ids and paths are. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 066d3d82db..a198810b41 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -734,8 +734,9 @@ fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { - let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) }) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 800a57c795..efce2e7a47 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -105,6 +105,11 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. + let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); + let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); assert_eq!(reply["error"], "file:///b.svg", "{reply}"); From f9773f2907ccc22dae1521e3ee99a9725dd25467 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:42:16 -0800 Subject: [PATCH 028/126] EPS: Save As shows Use Artboards, off by default, and the box fits all visible art Save and Save As as EPS always wrote the chosen artboard as the page and %%BoundingBox, and the EPS Options hid Use Artboards, so art past the artboard was left outside the file's bounding box. The EPS Options of Save As now show Use Artboards with All or Range under it, unchecked by default. Unchecked, Save writes one file whose bounding box fits all the visible art, as Export does, without the artboards' background colors; with no visible art the page is the first artboard, in its color. Checked with two or more chosen artboards, the file at the path is the central file of the visible art and each artboard is also written to <name>_<artboard>.eps beside it. Checked with one, that artboard is the page of the file at the path, the behavior Save had since #4293 (mockydevs). The file the document becomes still carries the whole editable document. Naming artboards with artboard, artboards or range still turns Use Artboards on. file.formatOptions now reports it on, with those artboards as the range, and the Save As dialog opens that way. Changed default: an EPS saved with Save or Save As, an opened EPS included, now gets a bounding box around its visible art unless Use Artboards is checked or the save names artboards; an opened EPS saved back this way loses the margins its page had. selectedOnly stays an Export option, and Save refuses it. Docs: params text of document.save and file.formatOptions, the useArtboards option text, docs/mcp.md save_file, docs/file-format-parity.md, docs/target-app-parity.md, and notes on the earlier EPS Save evidence in docs/gaps.md, docs/roadmap.md and docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/eps.rs | 52 +++++++---- crates/engine/src/cmd/fileio/save.rs | 29 +++++- .../src/cmd/fileio/tests_artboard_fill.rs | 46 +++++++--- crates/engine/src/tests/save.rs | 91 +++++++++++++++++-- crates/ui-egui/src/dialogs/save_options.rs | 53 +++++++---- .../ui-egui/src/dialogs/save_options/tests.rs | 56 +++++++++++- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- docs/file-format-parity.md | 30 ++++-- docs/gaps.md | 4 +- docs/mcp.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 4 +- 13 files changed, 290 insertions(+), 83 deletions(-) diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index ac33326994..a87c782264 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -47,7 +47,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "useArtboards", ty: "boolean", default: "null", - description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document (none when no art shows); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named", + description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document (when no art shows, Export writes none and Save writes it on the first artboard); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named (Save: the first artboard when no art shows)", }, FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, FormatOption { @@ -202,10 +202,15 @@ fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Re } } +/// One file to write: its artboard, region and origin. +type Page = (Option<usize>, Rect, Point); + /// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner /// as the origin, or the visible art's bounds with the first artboard's corner. With two or more /// chosen artboards, a central file of the visible art comes first (none when no art shows). -fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Point)>> { +/// `saving`: Save always writes the file it names, so with no visible art that file's region is +/// the first artboard, and the second value is true. +fn pages(doc: &Document, set: &Settings, saving: bool) -> Result<(Vec<Page>, bool)> { let n = doc.artboards.len(); let named = set.boards.resolve(n).map_err(|e| bad(C, e))?; let boards = match (set.use_artboards, named) { @@ -216,17 +221,25 @@ fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Poi }; let corner = |r: Rect| Point::new(r.x0, r.y1); let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect))); + // The visible art's bounds; for Save with no visible art, the first artboard (true). + let visible = || match vectorcraft_render::encode::art_bounds(doc) { + Some(bounds) => Some((bounds, false)), + None if saving => doc.artboards.first().map(|a| (a.rect, true)), + None => None, + }; match boards { Some(boards) => { - let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten(); + let central = (boards.len() > 1).then(visible).flatten(); let each = boards .into_iter() .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))); - central.map(Ok).into_iter().chain(each).collect() + let pages = central.map(|(bounds, _)| Ok(art(bounds))).into_iter().chain(each).collect::<Result<_>>()?; + Ok((pages, central.is_some_and(|(_, blank)| blank))) } None => { - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - Ok(vec![art(bounds)]) + let missing = if saving { "the document has no artboard" } else { "nothing to export: the document has no visible art" }; + let (bounds, blank) = visible().ok_or_else(|| bad(C, missing))?; + Ok((vec![art(bounds)], blank)) } } } @@ -268,16 +281,17 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> { encode_document(doc, p, false) } -/// Save keeps a single page's original frame and all native editing data, unlike Export's -/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply. +/// Save writes the pages Export does (the visible art's bounds, or with Use Artboards the chosen +/// artboards), with a smaller preview. A single file, the one the document becomes, carries all +/// the native editing data; with no visible art its page is the first artboard. pub(super) fn encode_save(doc: &Document, p: &Value) -> Result<Encoded> { encode_document(doc, p, true) } fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let set = parse(p)?; - if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) { - return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS")); + if saving && bool_or(p, "selectedOnly", false) { + return Err(bad(C, "EPS Save writes all artwork: use document.export for selected-only EPS")); } let original = doc; let (full, warnings) = crate::cmd::place::document::full_documents(doc); @@ -290,18 +304,16 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { } else { Cow::Borrowed(original) }; - let pages = if saving { - let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?; - let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect; - vec![(Some(board), rect, Point::new(rect.x0, rect.y1))] - } else { - pages(doc, &set)? - }; + let (pages, blank) = pages(doc, &set, saving)?; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } - // The artboards' background colors, behind the art (the editing data keeps them as colors). - let filled = super::export::with_artboard_fills(doc, p, pages.iter().filter_map(|(b, ..)| *b)).map_err(|e| bad(C, e))?; + // The file Save writes alone becomes the document's own, so it carries every artboard. + let whole = saving && pages.len() == 1; + // The artboards' background colors, behind the art (the editing data keeps them as colors); + // a page on the first artboard because no art shows has that artboard's color. + let fills = pages.iter().filter_map(|(b, ..)| *b).chain(blank.then_some(0)); + let filled = super::export::with_artboard_fills(doc, p, fills).map_err(|e| bad(C, e))?; let doc = &*filled; let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect(); let expanded = expand_transparent_tile_images(doc, &set.flatten)?; @@ -328,7 +340,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { origin, title: doc.title.clone(), created, - native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), + native: Some(native(&editing, if whole { None } else { board }, set.include_linked)), ..set.eps.clone() }; let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..da710553bf 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -870,8 +870,10 @@ impl SaveJob { _ => encode_all(doc, f.id, &self.params), } .map_err(|e| named_for_save(cmd, e))?; - // A legacy .ai file per artboard. - let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?; + // A legacy .ai file per artboard, or the files the EPS encoder writes: one, or with Use + // Artboards and two or more artboards the central file at the path and a file per + // artboard beside it. + let per_artboard = f.id == "eps" || (f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?); if enc.files.len() != 1 && !per_artboard { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } @@ -1092,6 +1094,25 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> { } } } + // An EPS save that named artboards without Use Artboards had it on (the encoder reads them + // so): it shows on, with those artboards as the range. + if f.id == "eps" + && let Some(m) = &saved + && m.get("useArtboards").is_none_or(Value::is_null) + { + let range = m.get("range").and_then(Value::as_str).filter(|r| !r.trim().is_empty()); + let boards = m + .get("artboards") + .and_then(|a| serde_json::from_value::<Vec<usize>>(a.clone()).ok()) + .or_else(|| m.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).map(|i| vec![i])) + .filter(|b| !b.is_empty()); + if range.is_some() || boards.is_some() { + v["options"]["useArtboards"]["value"] = json!(true); + if let (None, Some(boards)) = (range, boards) { + v["options"]["range"]["value"] = json!(range_text(&boards)); + } + } + } v["saveFormats"] = SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); Ok(v) @@ -1104,7 +1125,7 @@ pub(super) fn specs() -> Vec<CommandSpec> { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -1158,7 +1179,7 @@ pub(super) fn specs() -> Vec<CommandSpec> { "Format Options", [], None, - "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default", + "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default (EPS: useArtboards is true when the last save named artboards without it, and range then names them)", always, format_options ), diff --git a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs index 9a7684686a..371cdc05ab 100644 --- a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs +++ b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs @@ -109,23 +109,25 @@ fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() { } } +/// Does PostScript `t` paint the only red the document has, the first artboard's color? RGB, or +/// CMYK PostScript's (its values depend on the color conversion the platform offers, so only its +/// hue is checked). +fn red(t: &str) -> bool { + t.lines().any(|l| { + let mut w: Vec<&str> = l.split_whitespace().collect(); + let Some(op) = w.pop() else { return false }; + let v: Vec<f64> = w.iter().filter_map(|n| n.parse().ok()).collect(); + match (op, v.as_slice()) { + ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, + ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, + _ => false, + } + }) +} + #[test] fn eps_and_postscript_print_draw_the_color() { let mut s = session(); - // The only red the document has, the first artboard's color: RGB, or CMYK PostScript's (its - // values depend on the color conversion the platform offers, so only its hue is checked). - let red = |t: &str| { - t.lines().any(|l| { - let mut w: Vec<&str> = l.split_whitespace().collect(); - let Some(op) = w.pop() else { return false }; - let v: Vec<f64> = w.iter().filter_map(|n| n.parse().ok()).collect(); - match (op, v.as_slice()) { - ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, - ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, - _ => false, - } - }) - }; let eps = |s: &mut Session, more: Value| { String::from_utf8_lossy(&file(s, with(json!({"format": "eps", "useArtboards": true, "artboard": 0}), &more))).into_owned() }; @@ -138,6 +140,22 @@ fn eps_and_postscript_print_draw_the_color() { assert!(!red(&ps(&mut s, json!({"includeArtboardFill": false}))), "print without it"); } +/// EPS Save draws the color on an artboard's page: with Use Artboards, or on the first artboard +/// when no art shows. The box of the visible art, as Export's, has none. +#[test] +fn eps_save_draws_the_color_on_an_artboard_page() { + let mut s = session(); + let save = |s: &mut Session, more: Value| { + let options = with(json!({"previewFormat": "none", "thumbnails": false}), &more); + String::from_utf8_lossy(&b64(&s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap())).into_owned() + }; + assert!(!red(&save(&mut s, json!({}))), "the art's box"); + assert!(red(&save(&mut s, json!({"useArtboards": true, "range": "1"}))), "the artboard's page"); + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(red(&save(&mut s, json!({}))), "the first artboard when no art shows"); +} + #[test] fn export_for_screens_includes_the_fill_unless_unchecked() { let mut s = session(); diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index d92365a356..2b31bddb3b 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -425,8 +425,16 @@ fn transparency_grid_round_trips_without_dirtying() { let _ = std::fs::remove_dir_all(d); } +/// The `%%BoundingBox` of an EPS file's PostScript. +fn bounding_box(eps: &[u8]) -> [i64; 4] { + let ps = std::str::from_utf8(vectorcraft_eps::sections(eps).unwrap().0).unwrap(); + let line = ps.lines().find_map(|l| l.strip_prefix("%%BoundingBox:")).expect("a bounding box"); + let v: Vec<i64> = line.split_whitespace().map(|n| n.parse().unwrap()).collect(); + [v[0], v[1], v[2], v[3]] +} + #[test] -fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { +fn opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page() { let d = dir("eps-original-format"); let eps_path = path(&d, "Artwork.eps"); // Original, contributor-authored PostScript: the artwork occupies only part of the page. @@ -440,11 +448,16 @@ fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { assert_eq!((board.width(), board.height()), (200.0, 100.0)); rect(&mut s); let edited_layers = s.doc().unwrap().doc.layers.clone(); - let r = s.execute("document.save", &json!({"options": {"previewFormat": "none", "thumbnails": false}})).unwrap(); + // Without Use Artboards the bounding box fits the art, as Export draws it; the editing data + // keeps the page. + let quiet = json!({"previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"options": quiet})).unwrap(); assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps"))); let bytes = std::fs::read(&eps_path).unwrap(); assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); - assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); + let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); + assert_eq!(bounding_box(&bytes), bounding_box(&exported)); + assert_ne!(bounding_box(&bytes), [0, 0, 200, 100], "the art does not fill the page"); let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(editing.artboards[0].rect, board); assert_eq!(editing.layers, edited_layers); @@ -453,26 +466,86 @@ fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { assert_eq!(opened["restored"], true); assert_eq!(s.doc().unwrap().doc.layers, edited_layers); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); + // Use Artboards keeps the opened file's page. + rect(&mut s); + s.execute("document.save", &json!({"options": {"useArtboards": true, "previewFormat": "none", "thumbnails": false}})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); + // Save remembers it. rect(&mut s); s.execute("document.save", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); std::fs::remove_dir_all(d).unwrap(); } #[test] -fn eps_save_keeps_all_editing_artboards_while_printing_one_page() { +fn eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": -50, "y": 20, "width": 300, "height": 40})).unwrap(); + let quiet = json!({"previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"format": "eps", "options": quiet})).unwrap(); + let [x0, y0, x1, y1] = bounding_box(&b64(&r)); + assert!(x0 <= -50 && x1 >= 250 && y0 >= 0 && y1 <= 100, "{:?}", [x0, y0, x1, y1]); + for (use_artboards, expected) in [(false, [x0, y0, x1, y1]), (true, [0, 0, 200, 100])] { + let options = json!({"useArtboards": use_artboards, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap(); + assert_eq!(bounding_box(&b64(&r)), expected, "useArtboards: {use_artboards}"); + } +} + +#[test] +fn eps_save_keeps_all_editing_artboards_in_the_file_of_one_page() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap(); let bytes = b64(&r); + assert!(r.get("files").is_none(), "{r}"); + let board = s.doc().unwrap().doc.artboards[1].rect; + let [x0, y0, x1, y1] = bounding_box(&bytes); + assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.width(), board.height())); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards.len(), 2); assert_eq!(native.layers, s.doc().unwrap().doc.layers); - assert!(s.execute("document.save", &json!({"format": "eps", "range": "all"})).is_err(), "EPS is one printed page"); - for options in [json!({"useArtboards": false}), json!({"selectedOnly": true})] { - assert!(s.execute("document.save", &json!({"format": "eps", "options": options})).is_err(), "cropping is Export's job"); + assert!(s.execute("document.save", &json!({"format": "eps", "options": {"selectedOnly": true}})).is_err(), "selected-only is Export's job"); +} + +#[test] +fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { + let d = dir("eps-artboards"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let files: Vec<String> = r["files"].as_array().expect("files").iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 3, "{r}"); + assert_eq!(files[0], eps_path, "the file the document becomes comes first"); + assert!(files[1..].iter().all(|f| f.starts_with(&path(&d, "Art_")) && f.ends_with(".eps")), "{files:?}"); + // The central file: the visible art and the whole document. + let central = std::fs::read(&eps_path).unwrap(); + let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); + assert_eq!(bounding_box(¢ral), bounding_box(&exported)); + assert_eq!(vectorcraft_format::load(&vectorcraft_eps::native(¢ral).unwrap()).unwrap().artboards.len(), 2); + // Each artboard's file: its artboard as the page, holding that artboard. + let boards = s.doc().unwrap().doc.artboards.clone(); + for (f, board) in files[1..].iter().zip(&boards) { + let bytes = std::fs::read(f).unwrap(); + let [x0, y0, x1, y1] = bounding_box(&bytes); + assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.rect.width(), board.rect.height()), "{f}"); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [board.rect], "{f}"); } + let st = s.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(eps_path.as_str()), false)); + // With no visible art the central file is still written, on the first artboard. + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + std::fs::remove_file(&eps_path).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); + std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -550,7 +623,7 @@ fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap(); rect(&mut s); // Explicit colour preview remains a colour preview; Save bounds its resolution, not the art. - let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap(); + let r = s.execute("document.save", &json!({"format": "eps", "useArtboards": true, "previewFormat": "tiffColor"})).unwrap(); let bytes = b64(&r); let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); let tiff = tiff.unwrap(); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 3d0cb14421..6381f1e863 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -7,8 +7,10 @@ //! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). //! Native and `.ai` files show their save options (VectorCraft Options after Save As): each //! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, -//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. A native file's Version is a -//! menu of the versions VectorCraft writes (`version`, named by the releases that read each). A +//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. EPS shows Use Artboards +//! (`useArtboards`, off by default: the bounding box fits the visible art) with the same `all` or +//! `range` under it. A native file's Version is a menu of the versions VectorCraft writes +//! (`version`, named by the releases that read each). A //! `.ai` file's Version comes first: PDF-compatible, or legacy version 7 (`aiVersion: "7"`, a file //! per artboard), which takes only Transparent Images (`transparentImages`), Hidden Objects //! (`hiddenObjects`: a hidden layer, omitted or made visible), Text (`outlineText`: editable or @@ -76,22 +78,33 @@ fn separates(f: &Format) -> bool { f.options.iter().any(|o| o.name == "separateArtboards") } +/// The checkbox that `f`'s All or Range shows under: Save each artboard to a separate file, or +/// for EPS Use Artboards (off: the visible art's bounds). +fn board_switch(f: &Format) -> Option<&'static str> { + if separates(f) { + Some("separateArtboards") + } else if f.id == "eps" { + Some("useArtboards") + } else { + None + } +} + /// The options the dialog shows: arrays and objects are for agents, as is a native file's /// `precision`, and a page range replaces the single artboard choice; none for a format with its /// own dialog. A `.ait` template has no Version: Save as Template writes it PDF-compatible only. fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> { let range = f.options.iter().any(|o| o.name == "range"); - let separate = separates(f); + let switched = board_switch(f).is_some(); let options = if own_dialog(f) { &[][..] } else { f.options }; options.iter().filter(move |o| { !(matches!(o.ty, "array" | "object") || o.name == "precision" || (range && o.name == "artboard") - || (separate && o.name == "range") + || (switched && o.name == "range") || (matches!(f.id, "ai" | "ait") && o.name == "useArtboards") - // EPS Save keeps a page and the complete editing document; cropped and - // selected-only output belongs to Export, never this save dialog. - || (f.id == "eps" && matches!(o.name, "useArtboards" | "selectedOnly")) + // EPS Save writes all the artwork: selected-only output belongs to Export. + || (f.id == "eps" && o.name == "selectedOnly") || (f.id == "ait" && o.name == "aiVersion")) }) } @@ -101,6 +114,7 @@ fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> { fn label(o: &FormatOption) -> String { match o.name { "separateArtboards" => tl!("Save each artboard to a separate file").into(), + "useArtboards" => tl!("Use Artboards").into(), "includeLinked" => tl!("Include Linked Files").into(), "embedProfiles" => tl!("Embed ICC Profiles").into(), "pdfCompatible" => tl!("Create PDF-Compatible File").into(), @@ -110,16 +124,16 @@ fn label(o: &FormatOption) -> String { } } -/// Every artboard (the All choice under Save each artboard to a separate file): `all` when set, -/// else no range given. +/// Every artboard (the All choice under Save each artboard to a separate file or Use Artboards): +/// `all` when set, else no range given. fn all_artboards(d: &Dialog) -> bool { d.fields.get("all").and_then(Value::as_bool).unwrap_or_else(|| d.str("range").trim().is_empty()) } -/// All or Range, under Save each artboard to a separate file (enabled while it is on). -fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) { +/// All or Range, under the checkbox `switch` (enabled while it is on). +fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog, switch: &str) { ui.label(""); - ui.add_enabled_ui(d.bool("separateArtboards"), |ui| { + ui.add_enabled_ui(d.bool(switch), |ui| { ui.horizontal(|ui| { ui.add_space(22.0); let mut all = all_artboards(d); @@ -392,8 +406,8 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma d.fields.insert(o.name.into(), json!(!on)); } ui.end_row(); - if o.name == "separateArtboards" { - artboard_range(ui, d); + if matches!(o.name, "separateArtboards" | "useArtboards") { + artboard_range(ui, d, o.name); } return; } @@ -505,8 +519,15 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { if !boards.iter().all(|b| *b) { options.insert("range".into(), json!(super::export_for_screens::range_text(&boards))); } - } else if separates(f) { - if !d.bool("separateArtboards") || all_artboards(d) { + } else if let Some(switch) = board_switch(f) { + if switch == "useArtboards" { + // All or Range alone names the artboards: an artboard remembered from an earlier + // save would otherwise turn Use Artboards on, or narrow All to itself. + options.remove("artboard"); + options.remove("artboards"); + options.insert(switch.into(), json!(d.bool(switch))); + } + if !d.bool(switch) || all_artboards(d) { options.remove("range"); } else { // A bad range keeps the dialog open. diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 9590799687..68a88eaf11 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -346,19 +346,65 @@ fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); } +/// The `%%BoundingBox` line of an EPS file. +fn bounding_box(eps: &[u8]) -> String { + let ps = std::str::from_utf8(vectorcraft_eps::sections(eps).unwrap().0).unwrap(); + ps.lines().find(|l| l.starts_with("%%BoundingBox:")).expect("a bounding box").to_string() +} + #[test] -fn eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches() { +fn eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard() { let (mut app, written, _) = desktop("art.eps"); app.run("file.saveAs", json!({})).unwrap(); let names: Vec<_> = shown(fileio::format("eps").unwrap()).map(|option| option.name).collect(); - assert!(!names.contains(&"useArtboards") && !names.contains(&"selectedOnly")); + assert!(names.contains(&"useArtboards") && !names.contains(&"selectedOnly") && !names.contains(&"range"), "{names:?}"); assert!(names.contains(&"previewFormat")); + // EPS alone gains the row: the other save formats keep their own checkbox or none. + for id in SAVE_FORMATS.iter().chain(TEMPLATE_FORMATS).filter(|id| **id != "eps") { + let f = fileio::format(id).unwrap(); + assert!(!shown(f).any(|o| o.name == "useArtboards") && board_switch(f) != Some("useArtboards"), "{id}"); + } + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + assert!(text.contains("Use Artboards") && text.contains("All") && text.contains("Range:"), "{text}"); + assert!(!dialog(&app).bool("useArtboards"), "Use Artboards starts off"); super::super::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); - let saved = written.borrow(); - assert_eq!(saved[0].0, "art.eps"); - assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + { + let saved = written.borrow(); + assert_eq!(saved[0].0, "art.eps"); + assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + // The box Export writes of the visible art, not the artboard. + let exported = app.session.execute("document.export", &json!({"format": "eps"})).unwrap(); + let exported = vectorcraft_format::base64_decode(exported["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(bounding_box(&saved[0].1), bounding_box(&exported)); + assert_ne!(bounding_box(&saved[0].1), "%%BoundingBox: 0 0 100 80"); + } assert_eq!(app.session.active().unwrap().format, "eps"); + // Checked, the artboard is the page, and an artboard remembered from an earlier save doesn't + // narrow All. + app.run("file.saveAs", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("useArtboards".into(), json!(true)); + d.fields.insert("artboard".into(), json!(3)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + { + let saved = written.borrow(); + assert_eq!(saved.len(), 2, "one artboard: one file"); + assert_eq!(bounding_box(&saved[1].1), "%%BoundingBox: 0 0 100 80"); + } + // A save that named an artboard without Use Artboards (as document.save {artboard} does) had + // it on: the dialog shows it on, on that artboard, and OK writes the artboard again. + let st = app.session.active_mut().unwrap(); + st.save_options.remove("useArtboards"); + st.save_options.insert("artboard".into(), json!(0)); + app.run("file.saveAs", json!({})).unwrap(); + assert!(dialog(&app).bool("useArtboards")); + assert_eq!(dialog(&app).str("range"), "1"); + super::super::confirm(&mut app).unwrap(); + let saved = written.borrow(); + assert_eq!(saved.len(), 3); + assert_eq!(bounding_box(&saved[2].1), "%%BoundingBox: 0 0 100 80"); } /// #4931: a native file's Version is a menu of the versions VectorCraft writes, the current one diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..22cb4cefcd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6335,7 +6335,7 @@ antiAlias Sharp शार्प @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} @msg EPS flattener: {e} ईपीएस flattener: {e} @msg EPS options: {e} ईपीएस ऑप्शन्स: {e} -@msg EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS EPS सेव आर्टबोर्ड और पूरा आर्टवर्क रखता है: क्रॉप किए या केवल सिलेक्ट किए आर्टवर्क के EPS के लिए document.export उपयोग करें +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS सेव पूरा आर्टवर्क लिखता है: केवल सिलेक्ट किए आर्टवर्क के EPS के लिए document.export उपयोग करें @msg every numbered name for {name} is taken in the document's folder {name} के लिए हर नंबर का नाम डॉक्यूमेंट के फ़ोल्डर में लिया जाता है @msg every numbered name for it is taken in the Fonts folder इसके लिए हर नंबर वाला नाम फ़ॉन्ट फ़ोल्डर में लिया जाता है @msg every numbered name for the copy is taken प्रति के लिए हर नंबर का नाम लिया जाता है diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..1b509aaa9f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -7388,7 +7388,7 @@ digits Hindi Hindi @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} @msg EPS flattener: {e} EPS-utplattning: {e} @msg EPS options: {e} EPS-alternativ: {e} -@msg EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS När du sparar som EPS behålls ritytan och allt bildmaterial: använd document.export för beskuren EPS eller EPS med bara det markerade +@msg EPS Save writes all artwork: use document.export for selected-only EPS När du sparar som EPS skrivs allt bildmaterial: använd document.export för EPS med bara det markerade @msg every numbered name for the result is taken alla numrerade namn för resultatet är upptagna @msg expanded SVG styles exceed the import size limit de expanderade SVG-stilarna överskrider storleksgränsen för import @msg expected {"dataBase64"} förväntade {"dataBase64"} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df0..afaf5955b1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -486,25 +486,39 @@ Stop opacity on translated artboards remains outside this regression coverage. Feature evidence updated 2026-10-26; scores and estimates unchanged. -EPS is offered by Save As, and an opened EPS keeps its own path for ordinary Save. Save writes -one artboard (the first by default, or the named artboard), retaining its page bounds and transparent -margins rather than cropping to the drawn artwork. The whole editable VectorCraft document remains -embedded in EPS comments, including other artboards. Save bounds the optional TIFF preview to +EPS is offered by Save As, and an opened EPS keeps its own path for ordinary Save. The EPS +Options of Save As show Use Artboards with All or Range under it, for a separate file per artboard +([saving in EPS](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/save-artwork-in-eps-format.html)), +unchecked by default (the scripting reference's EPS save options list `saveMultipleArtboards` with +a default of false, +[scripting reference](https://web.archive.org/web/20160803105056/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20JavaScript%20Scripting%20Reference.pdf)). +Unchecked, Save writes one file whose bounding box fits all the visible art, as Export does, +without the artboards' background colors; an opened EPS saved back this way loses the margins its +page had. With no visible art the page is the first artboard, in its color. Naming artboards with +`artboard`, `artboards` or `range` turns Use Artboards on. Checked with two or more chosen, the +file at the path is the central file of the visible art and each artboard is also written to +`<name>_<artboard>.eps` beside it, holding that artboard. Checked with one chosen, VectorCraft +writes that artboard as the page of the file at the path alone, retaining its bounds and +transparent margins. The file the document becomes carries the whole editable VectorCraft document +in EPS comments, including other artboards. Save bounds the optional TIFF preview to 1024 pixels on its longest side (about 4 MiB of RGBA pixels), avoiding the roughly 16 MiB preview a large artboard otherwise produces. Export keeps its existing 2048-pixel preview cap. Preview format choices are unchanged; no public preview-resolution option exists. The EPS PostScript appearance still flattens transparency; its existing warnings remain. This does not promise native Illustrator editing-data fidelity. -Cropped and selected-only EPS output remains available through Export. Save refuses those options. +Selected-only EPS output remains available through Export. Save refuses `selectedOnly`. An EPS opened from a preview, a partially interpreted program or damaged native editing data keeps its source-loss warning: writing over that original requires explicit confirmation. File › Save uses the same engine path and write-failure handling as other document formats. -Synthetic regressions: `tests::save::opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins`, -`eps_save_keeps_all_editing_artboards_while_printing_one_page`, +Synthetic regressions: `tests::save::opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page`, +`eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard`, +`eps_save_keeps_all_editing_artboards_in_the_file_of_one_page`, +`eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard`, +`cmd::fileio::tests_artboard_fill::eps_save_draws_the_color_on_an_artboard_page`, `eps_save_supports_an_empty_artboard_without_cropping_its_page`, `partially_read_eps_requires_confirmation_before_save_back`, `eps_with_damaged_editing_data_requires_confirmation_before_save_back`, `eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data`, and the UI regressions `file_save_writes_opened_eps_back_without_another_save_as_dialog` and -`eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches`. +`eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard`. diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..8b721454f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -198,7 +198,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. -- **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. +- **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. Since 2026-11-12 Save keeps the page margins only with Use Artboards checked; unchecked, the default, the bounding box fits the visible art. - **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. @@ -1093,4 +1093,4 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv 2026-10-28 behavior correction: Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..773a88b443 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -491,7 +491,7 @@ opens the picker through `run_command` with the remote backend. | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | -| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b55740614..a331fc5bbc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -379,4 +379,4 @@ separate. Scores and hours unchanged. [Behavior and command details](development 2026-10-28 behavior correction: Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..562b8530ec 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -613,6 +613,8 @@ since 2026-10-01. - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. +- **EPS Save with Use Artboards:** Save As › EPS Options show Use Artboards with All or Range, for a separate file per artboard ([saving in EPS](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/save-artwork-in-eps-format.html)), unchecked by default (the scripting reference's EPS save options list `saveMultipleArtboards` with a default of false, [scripting reference](https://web.archive.org/web/20160803105056/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20JavaScript%20Scripting%20Reference.pdf)). Unchecked, Save writes one file whose bounding box fits all the visible art, as Export does, without the artboards' background colors; when no art shows, the page is the first artboard in its color. An opened EPS saved back this way loses its page margins. Checked with two or more chosen artboards, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it. Checked with one chosen artboard, VectorCraft writes that artboard as the page of the file at the path alone (the behavior Save had since #4293). Evidence: `cmd::fileio::tests_artboard_fill::eps_save_draws_the_color_on_an_artboard_page`, `tests::save::eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard`, `tests::save::opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page`, `tests::save::eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard`, `dialogs::save_options::tests::eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard`. + - **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). @@ -2367,4 +2369,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. \ No newline at end of file From e9942b69d2fb0c64eae00a60feda4b488eee4d7e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:36:11 -0800 Subject: [PATCH 029/126] Help: a frame run while a file dialog waits is timed for the hang report The input hook, the logic and the interface of a frame now mark their time as timed whatever the activity around them, so a frame the system runs while an in-line file dialog waits for the user is timed too, and the dialog's wait is not timed again once the frame ends. --- apps/vectorcraft/src/app.rs | 6 +++--- crates/ui-egui/src/hang_beacon.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 288fa6cdc3..0391b7534b 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -35,7 +35,7 @@ impl eframe::App for App { // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - BEACON.enter(Stage::Logic); + let _timed = BEACON.enter_timed(Stage::Logic); file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); BEACON.enter(Stage::Idle); @@ -43,7 +43,7 @@ impl eframe::App for App { /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - BEACON.enter(Stage::Input); + let _timed = BEACON.enter_timed(Stage::Input); self.frame_start = Some(std::time::Instant::now()); // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). @@ -68,7 +68,7 @@ impl eframe::App for App { BEACON.enter(Stage::Idle); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - BEACON.enter(Stage::Interface); + let _timed = BEACON.enter_timed(Stage::Interface); #[cfg(target_os = "macos")] { let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs index 3331a1e39c..e47dbfc79c 100644 --- a/crates/ui-egui/src/hang_beacon.rs +++ b/crates/ui-egui/src/hang_beacon.rs @@ -196,6 +196,14 @@ impl Beacon { self.bump(); } + /// The interface thread enters `stage` of a frame, timed whatever the activity around it, so a + /// frame the system runs while a file dialog waits for the user is timed too. The activity + /// before it applies again when the guard is dropped. + pub fn enter_timed(&self, stage: Stage) -> ActivityGuard<'_> { + self.enter(stage); + self.activity(Activity::None) + } + /// The interface thread starts `activity`; it ends when the guard is dropped, and the activity /// before it applies again. pub fn activity(&self, activity: Activity) -> ActivityGuard<'_> { @@ -538,6 +546,20 @@ mod tests { assert!(B.take_input(), "the main window's input"); } + #[test] + fn a_frame_run_while_a_dialog_waits_is_timed() { + let b = Beacon::new(); + let _dialog = b.activity(Activity::Dialog); + assert!(!b.sample().busy(), "the dialog waits for the user"); + { + let _timed = b.enter_timed(Stage::Interface); + assert!(b.sample().busy(), "a frame inside the dialog's wait"); + b.enter(Stage::Drawing); + } + assert_eq!(b.sample().activity, Activity::Dialog, "the dialog again"); + assert!(!b.sample().busy()); + } + #[test] fn a_window_shown_in_line_handed_to_the_system_is_marked() { static B: Beacon = Beacon::new(); From a8989aff6dc8bdcc64fce808e1d10dfd99b18823 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:22:01 -0800 Subject: [PATCH 030/126] Commands: app and control channel params of the wrong type are refused view.setZoom, view.screenMode, window.panel, file.revert, file.close, ui.newSwatch, ui.symbolOptions, ui.batchDialog and file.exportSelection read a number or switch of the wrong type as its default and went on, so `view.setZoom {"zoom": "200"}` set 100%. They now refuse it and change nothing. The control channel's ui.key, ui.move, ui.wheel, ui.click, ui.drag, ui.pointer, ui.resize, ui.screenshot, ui.render and ui.set, and the headless session's key, pointer and render calls, do the same for their numbers, switches and mods, before any input is queued. Omitting a param or giving null keeps its default. --- crates/mcp/src/headless.rs | 38 +++++++-- crates/mcp/src/tests.rs | 28 +++++++ crates/ui-egui/src/control.rs | 125 +++++++++++++++++++++------- crates/ui-egui/src/control/tests.rs | 86 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/menus/run.rs | 82 ++++++++++++++---- docs/mcp.md | 13 +++ 7 files changed, 317 insertions(+), 57 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 00e914ddba..0377cd5ae1 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -56,6 +56,24 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } +/// An optional param read with `read`: none when it is omitted or null, else an error naming the +/// key when `read` gets nothing from its value. +fn opt<T>(p: &Value, k: &str, read: impl Fn(&Value) -> Option<T>, what: &str) -> Result<Option<T>, String> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => read(v).map(Some).ok_or_else(|| format!("`{k}` must be {what}")), + } +} + +fn opt_bool(p: &Value, k: &str) -> Result<Option<bool>, String> { + opt(p, k, Value::as_bool, "true or false") +} + +/// A pointer packet's `mods`: none when omitted or null, else an object of booleans. +fn packet_mods(p: &Value) -> Result<Option<Mods>, String> { + opt(p, "mods", |m| serde_json::from_value(m.clone()).ok(), "an object of true or false values") +} + pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> { Some(match k { "down" => PointerKind::Down, @@ -210,12 +228,13 @@ impl Headless { fn pointer(&mut self, p: &Value) -> Result<Value, String> { let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?; - let base: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let base = packet_mods(p)?.unwrap_or_default(); let default = PointerEvent::json_backquote(p, false)?; let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::<Result<Vec<_>, _>>()?; + let mods = events.iter().map(|e| Ok(packet_mods(e)?.unwrap_or(base))).collect::<Result<Vec<_>, String>>()?; self.ensure_document(); let mut requests = vec![]; - for (e, backquote) in events.iter().zip(held) { + for ((e, backquote), mods) in events.iter().zip(held).zip(mods) { let kind = s(e, "kind").unwrap_or(""); let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; if s(e, "space") == Some("screen") { @@ -223,7 +242,6 @@ impl Headless { } let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); let ev = PointerEvent { kind, pos: Point::new(x, y), @@ -253,8 +271,8 @@ impl Headless { fn key(&mut self, p: &Value) -> Result<Value, String> { let key = s(p, "key").ok_or("missing `key`")?; - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() }; + let b = |n: &str| opt_bool(p, n).map(|on| on.unwrap_or(false)); + let mods = Mods { shift: b("shift")?, alt: b("alt")?, cmd: b("cmd")?, ctrl: b("ctrl")?, ..Mods::default() }; // A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while // a selection marquee is dragged); otherwise it is a shortcut. let mut tk = tool_key(key); @@ -296,16 +314,18 @@ impl Headless { } fn render(&mut self, p: &Value) -> Result<Value, String> { + let idx = opt(p, "artboard", |v| v.as_u64().and_then(|i| usize::try_from(i).ok()), "a whole number of 0 or more")?.unwrap_or(0); + let scale = opt(p, "scale", Value::as_f64, "a number")?.unwrap_or(1.0).clamp(0.01, 16.0); + let trace_views = opt_bool(p, "traceViews")? == Some(true); + let data = opt_bool(p, "data")? == Some(true); let st = self.session.active().ok_or("no document")?; let doc = st.doc.clone(); - let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; - let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); vectorcraft_render::raster_size(r, scale)?; // Rendered with the options of a PNG export on white, so its pixels are the exported file's. // `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does. let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png); - opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true); + opts.trace_views = trace_views; let img = self.renderer.render_region_with(&doc, r, scale, &opts); // A cancelled render stops drawing: the picture is not whole and is not written. vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?; @@ -317,7 +337,7 @@ impl Headless { vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; out["path"] = json!(path); // `data: true` asks for the image as well as the file. - if p.get("data").and_then(Value::as_bool) == Some(true) { + if data { out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)); } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index d7de8dd7fb..dbe429a281 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1452,6 +1452,34 @@ fn headless_backend_methods() { assert!(h.call("ui.resize", json!({})).is_err()); } +/// The headless key, pointer and screenshot calls refuse a value of the wrong type and change +/// nothing; omitting it or giving null keeps its default. +#[test] +fn headless_methods_refuse_params_of_the_wrong_type() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap(); + h.call("ui.tool.select", json!({"tool": "rectangle"})).unwrap(); + let before = h.call("document.json", json!({})).unwrap(); + let drag = |mods: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60, "mods": mods}, {"kind": "up", "x": 60, "y": 60}]}); + for (method, params, key) in [ + ("ui.render", json!({"artboard": "0"}), "artboard"), + ("ui.render", json!({"scale": "2"}), "scale"), + ("ui.render", json!({"traceViews": 1}), "traceViews"), + ("ui.screenshot", json!({"data": "yes", "path": "x.png"}), "data"), + ("ui.key", json!({"key": "Delete", "shift": "true"}), "shift"), + ("ui.pointer", drag(json!({"shift": "yes"})), "mods"), + ("ui.pointer", json!({"mods": 1, "events": []}), "mods"), + ] { + let e = h.call(method, params.clone()).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); + } + assert_eq!(h.call("document.json", json!({})).unwrap(), before, "a refused call changes nothing"); + let r = h.call("ui.render", json!({"artboard": null, "scale": null, "traceViews": null})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(300), Some(200))); + h.call("ui.pointer", drag(Value::Null)).unwrap(); + assert_ne!(h.call("document.json", json!({})).unwrap(), before, "null mods keep the packet's"); +} + // ---------- workflow tools ---------- fn id_of(result: &Value) -> u64 { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b02c3b6e1d..77e931bdc1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -494,9 +494,23 @@ fn key_from(name: &str) -> Option<egui::Key> { /// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on /// macOS, Ctrl elsewhere). -fn modifiers(p: &Value) -> egui::Modifiers { - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } +fn modifiers(p: &Value) -> Result<egui::Modifiers, String> { + let b = |n: &str| crate::menus::opt_bool(p, n).map(|on| on.unwrap_or(false)); + let cmd = b("cmd")?; + Ok(egui::Modifiers { alt: b("alt")?, ctrl: b("ctrl")?, shift: b("shift")?, mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd }) +} + +/// An optional number param of a control request as `f32`, `default` when it is omitted or null. +fn coord(p: &Value, key: &str, default: f32) -> Result<f32, String> { + crate::menus::opt_number(p, key).map(|n| n.map_or(default, |n| n as f32)) +} + +/// A pointer packet's `mods`: none when omitted or null, else an object of booleans. +fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { + match value { + None | Some(Value::Null) => Ok(None), + Some(m) => serde_json::from_value(m.clone()).map(Some).map_err(|e| format!("`mods` must be an object of true or false values: {e}")), + } } pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { @@ -532,10 +546,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; + let m = match modifiers(p) { + Ok(m) => m, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) { return err(e); } - let m = modifiers(p); app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); // Typed text comes with the press, so it shares the key's frame and modifiers. if let Some(t) = s("text") { @@ -546,31 +563,39 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.move" => { + let pos = match coord(p, "x", 0.0).and_then(|x| Ok(egui::pos2(x, coord(p, "y", 0.0)?))) { + Ok(pos) => pos, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 1) { return err(e); } - let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); app.synthetic.push(egui::Event::PointerMoved(pos)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => synthetic_click(app, ctx, req), "ui.wheel" => { + // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways + // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. + let given = + (|| Ok::<_, String>(([coord(p, "dx", 0.0)?, coord(p, "dy", 0.0)?, coord(p, "x", 0.0)?, coord(p, "y", 0.0)?], modifiers(p)?)))(); + let ([dx, dy, x, y], modifiers) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 2) { return err(e); } - // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways - // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let unit = if s("unit") == Some("point") { egui::MouseWheelUnit::Point } else { egui::MouseWheelUnit::Line }; // Notches, not a flood: a turn stays within what a real wheel sends. - let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + let delta = egui::vec2(dx, dy).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, - modifiers: modifiers(p), + modifiers, source: egui::MouseWheelSource::Unknown, }); ctx.request_repaint(); @@ -635,8 +660,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.resize" => { - let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32; - let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32; + let (w, h) = match coord(p, "width", 1440.0).and_then(|w| Ok((w, coord(p, "height", 900.0)?))) { + Ok(size) => size, + Err(e) => return err(e), + }; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(Value::Null) } @@ -649,32 +676,48 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) { return err(e); } - if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { + let flags = crate::menus::opt_bool(p, "focus").and_then(|focus| Ok((focus, crate::menus::opt_bool(p, "data")?))); + let (focus, data) = match flags { + Ok(flags) => flags, + Err(e) => return err(e), + }; + if focus.unwrap_or(true) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } ctx.request_repaint(); - Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) } + Outcome::Screenshot { path: s("path").map(str::to_string), data: data == Some(true) } } "ui.render" => { + let given = (|| { + Ok::<_, String>(( + crate::menus::opt_index(p, "artboard")?, + crate::menus::opt_number(p, "scale")?, + crate::menus::opt_bool(p, "traceViews")?, + crate::menus::opt_bool(p, "data")?, + )) + })(); + let (index, scale, trace_views, data) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); // The artboard asked for (0-based, as headless), else the first. - let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok()); - let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; - let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); + let Some(r) = doc.artboards.get(index.unwrap_or(0)).map(|a| a.rect) else { return err("no such artboard") }; + let scale = scale.unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); } // Rendered with the options of a PNG export on white, so its pixels are the exported file's. // `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does. let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png); - opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true); + opts.trace_views = trace_views == Some(true); let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts); let png = match img.to_png() { Ok(png) => png, Err(e) => return err(e), }; - let data = p.get("data").and_then(Value::as_bool) == Some(true); + let data = data == Some(true); match s("path") { Some(path) => match app.services.write.as_mut() { Some(w) => wrap( @@ -926,7 +969,21 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con if rotating && let Err(error) = validate_rotate_view_events(p, events) { return err(error); } - let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + // Every event is read before any is sent, so a malformed packet sends none of them. + let read = packet_mods(p.get("mods")).and_then(|base| { + let base = base.unwrap_or_default(); + events + .iter() + .map(|e| { + let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); + Ok((at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) + }) + .collect::<Result<Vec<_>, String>>() + }); + let read = match read { + Ok(read) => read, + Err(e) => return err(e), + }; let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); let from = app.session.input_count(); @@ -936,7 +993,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Ok(held) => held, Err(e) => return err(e), }; - for (e, backquote) in events.iter().zip(held) { + for ((e, backquote), (x, y, mods)) in events.iter().zip(held).zip(read) { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, "drag" => PointerKind::Drag, @@ -945,10 +1002,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con "doubleclick" | "dblclick" => PointerKind::DoubleClick, other => return err(format!("unknown pointer kind `{other}`")), }; - let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); let screen = e.get("space").and_then(Value::as_str) == Some("screen"); - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); if rotating { if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { return err(e); @@ -1007,18 +1061,24 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { return err("synthetic input event count is out of range"); }; + // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let drag = req.method == "ui.drag"; + let given = (|| { + let a = egui::pos2(coord(p, "x", 0.0)?, coord(p, "y", 0.0)?); + let end = if drag { egui::pos2(coord(p, "toX", 0.0)?, coord(p, "toY", 0.0)?) } else { a }; + Ok::<_, String>((a, end, modifiers(p)?)) + })(); + let (a, end, modifiers) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, events) { return err(e); } - // Screen-space (egui points) pointer input through egui itself: reaches every widget. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, }; - let modifiers = modifiers(p); - let a = egui::pos2(f("x"), f("y")); - let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { @@ -1040,6 +1100,11 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + // A flag of the wrong type is refused before anything is set. + let flags = ["rulers", "outline", "grid", "smartGuides", "boundingBox", "controlBar", "imageTracePreview"]; + if let Some(e) = flags.iter().find_map(|k| crate::menus::opt_bool(p, k).err()) { + return err(e); + } // Outline is a setting of the active document's view: without one, nothing is set. if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() { return err("no document"); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0a9c042a1e..2cdf13a4d7 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -314,6 +314,92 @@ fn render_takes_the_artboard_asked_for() { assert_eq!(got, size.map(Some), "{params}: {r}"); } assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); + for (params, key) in [ + (json!({"artboard": "1"}), "artboard"), + (json!({"artboard": -1}), "artboard"), + (json!({"scale": "0.5"}), "scale"), + (json!({"traceViews": 1}), "traceViews"), + (json!({"data": "yes"}), "data"), + ] { + let r = request("ui.render", params.clone()); + assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{params}: {r}"); + } + let r = request("ui.render", json!({"artboard": null, "scale": null})); + assert_eq!([&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64), [Some(64), Some(64)], "null keeps the default: {r}"); +} + +/// The control channel's input methods refuse a number or switch of the wrong type before they +/// queue any event; omitting it keeps its default. +#[test] +fn control_input_refuses_params_of_the_wrong_type() { + let move_to = |mods: Value| json!({"events": [{"kind": "move", "x": 5, "y": 5}, {"kind": "move", "x": 6, "y": 6, "mods": mods}]}); + for (method, params, key) in [ + ("ui.move", json!({"x": "10"}), "x"), + ("ui.move", json!({"y": true}), "y"), + ("ui.wheel", json!({"dy": "1"}), "dy"), + ("ui.wheel", json!({"dy": 1, "shift": "yes"}), "shift"), + ("ui.click", json!({"x": [1]}), "x"), + ("ui.click", json!({"cmd": 1}), "cmd"), + ("ui.drag", json!({"toX": "5"}), "toX"), + ("ui.key", json!({"key": "Enter", "alt": "true"}), "alt"), + ("ui.resize", json!({"width": "800"}), "width"), + ("ui.screenshot", json!({"focus": "no"}), "focus"), + ("ui.screenshot", json!({"data": 1}), "data"), + ("ui.set", json!({"rulers": "true"}), "rulers"), + ("ui.set", json!({"grid": true, "controlBar": 0}), "controlBar"), + ("ui.pointer", json!({"events": [{"kind": "move", "x": "5", "y": 0}]}), "x"), + ("ui.pointer", move_to(json!({"shift": "yes"})), "mods"), + ("ui.pointer", json!({"mods": "shift", "events": [{"kind": "move", "x": 1, "y": 1}]}), "mods"), + ] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic.push(egui::Event::Text("pending".into())); + let before = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar); + let r = input_request(&mut app, method, params.clone()); + assert_eq!(r["ok"], false, "{method} {params}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{method} {params}: {r}"); + let after = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar); + assert_eq!(after, before, "{method} {params}: nothing is queued or set"); + } + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert_eq!(input_request(&mut app, "ui.move", json!({"x": null, "y": 4}))["ok"], true); + assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(0.0, 4.0))); +} + +/// The app's own commands refuse a param of the wrong type and change nothing; omitting it or +/// giving null keeps its default. +#[test] +fn ui_commands_refuse_params_of_the_wrong_type() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("view.setZoom", json!({"zoom": 300, "center": [5, 5]})).unwrap(); + let view = |app: &VectorcraftApp| app.view().map(|v| (v.zoom, v.center.x, v.center.y)); + let before = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone()); + for (id, params, key) in [ + ("view.setZoom", json!({"zoom": "200"}), "zoom"), + ("view.setZoom", json!({"zoom": 200, "center": [1]}), "center"), + ("view.setZoom", json!({"zoom": 200, "center": "0,0"}), "center"), + ("view.screenMode", json!({"mode": "2"}), "mode"), + ("view.screenMode", json!({"mode": 1.5}), "mode"), + ("window.panel", json!({"panel": "layers", "toggle": "true"}), "toggle"), + ("window.panel", json!({"panel": "layers", "toggle": true, "toggleGroup": 1}), "toggleGroup"), + ("file.revert", json!({"confirmed": "yes"}), "confirmed"), + ("file.close", json!({"index": "0"}), "index"), + ("ui.newSwatch", json!({"spot": "true"}), "spot"), + ("ui.newSwatch", json!({"global": 1}), "global"), + ("ui.symbolOptions", json!({"edit": 1}), "edit"), + ("ui.batchDialog", json!({"set": "1"}), "set"), + ("ui.batchDialog", json!({"action": -1}), "action"), + ("file.exportSelection", json!({"multiple": "true"}), "multiple"), + ] { + let e = app.run(id, params.clone()).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{id} {params}: {e}"); + let after = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone()); + assert_eq!(after, before, "{id} {params}: nothing changes"); + } + app.run("view.setZoom", json!({"zoom": null, "center": null})).unwrap(); + assert_eq!(app.view().map(|v| v.zoom), Some(crate::state::zoom_from_display_percent(100.0, app.session.prefs.display_print_size))); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3d3f2a8c03..7326a3157c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -75,7 +75,7 @@ pub(crate) use labels::lists_plugin; use labels::shows_a_name; pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; pub use run::{check_params, run_ui_command}; -pub(crate) use run::{normalize_panel, opt_bool}; +pub(crate) use run::{normalize_panel, opt_bool, opt_index, opt_number}; #[cfg(test)] use slots::RECENT_FONT_IDS; pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state}; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..febb8313ea 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -25,6 +25,34 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> { } } +/// An optional `{key?: number}` param: none when it is omitted or null. +pub(crate) fn opt_number(p: &Value, key: &str) -> Result<Option<f64>, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| format!("`{key}` must be a number")), + } +} + +/// An optional `{key?: index}` param, a whole number of 0 or more: none when it is omitted or null. +pub(crate) fn opt_index(p: &Value, key: &str) -> Result<Option<usize>, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| format!("`{key}` must be a whole number of 0 or more")), + } +} + +/// An optional `{key?: [x, y]}` param: none when it is omitted or null. +fn opt_point(p: &Value, key: &str) -> Result<Option<vectorcraft_geom::Point>, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_array().map(Vec::as_slice) { + Some([x, y]) => x.as_f64().zip(y.as_f64()).map(|(x, y)| Some(vectorcraft_geom::Point::new(x, y))), + _ => None, + } + .ok_or_else(|| format!("`{key}` must be [x, y] numbers")), + } +} + /// Handle a UI command. `None` = not a UI command (the engine handles it). /// `params` has only keys the params description of engine command `id` names, or the parameter /// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their @@ -48,13 +76,18 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> { // Window menu items and the named panel function keys toggle against the checked state; // other commands that open panels show them. - if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { + let toggles = if id == "window.panel" { opt_bool(p, "toggle").and_then(|t| Ok((t, opt_bool(p, "toggleGroup")?))) } else { Ok((None, None)) }; + let (toggle, toggle_group) = match toggles { + Ok(flags) => flags, + Err(e) => return Some(Err(e)), + }; + if toggle == Some(true) && checked(app, id, p) == Some(true) { let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); if app.ui.open_panel.as_deref() == Some(panel) { app.ui.open_panel = None; return Some(Ok(json!({"open": null}))); } - if p.get("toggleGroup").and_then(Value::as_bool) == Some(true) { + if toggle_group == Some(true) { return Some(crate::panel_docking::hide_group(app, panel)); } return Some(app.run("window.panel.close", json!({"panel": panel}))); @@ -131,7 +164,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< } // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), - "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), + "file.revert" if opt_bool(p, "confirmed") != Ok(Some(true)) => opt_bool(p, "confirmed").and_then(|_| io::ask_revert(app)), "file.reveal" => io::reveal(app), "file.cancelExport" => Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), @@ -386,9 +419,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< } "view.setZoom" => { let print = app.session.prefs.display_print_size; - let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); - let center = - p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let (zoom, center) = match opt_number(p, "zoom").and_then(|z| Ok((z, opt_point(p, "center")?))) { + Ok(given) => given, + Err(e) => return Some(Err(e)), + }; + let z = crate::state::zoom_from_display_percent(zoom.unwrap_or(100.0), print); match app.view_mut() { Some(v) => { v.zoom = z.clamp(0.0313, 640.0); @@ -417,7 +452,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< Ok(json!(app.ui.screen_mode)) } "view.screenMode" => { - app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { + let mode = match opt_index(p, "mode") { + Ok(mode) => mode, + Err(e) => return Some(Err(e)), + }; + app.ui.screen_mode = match mode { Some(m) => m.min(3) as u8, // F cycles the three screen modes. Without a mode, the command also goes from // Presentation Mode (not in the cycle) back to Normal. @@ -664,9 +703,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< // Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`). // Without an `index`, the current window closes: one of several views of a document closes // without closing the document (Window › New Window). - "file.close" => match p.get("index").and_then(Value::as_u64).map(|i| i as usize) { - Some(i) => crate::unsaved::close(app, i), - None => { + "file.close" => match opt_index(p, "index") { + Err(e) => Err(e), + Ok(Some(i)) => crate::unsaved::close(app, i), + Ok(None) => { app.session.active_index()?; crate::doc_windows::close_current(app) } @@ -678,8 +718,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< None => Err("missing `name`".into()), }, "ui.newSwatch" => { - let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) + let on = |k: &str| opt_bool(p, k).map(|on| on.unwrap_or(false)); + on("spot") + .and_then(|spot| Ok((spot, on("global")?))) + .and_then(|(spot, global)| crate::dialogs::new_swatch::open(app, spot, global, s("group").as_deref())) } "ui.newColorGroup" => { let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); @@ -697,7 +739,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< "ui.newParaStyle" => crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Para), "ui.symbolOptions" => { let name = s("name"); - let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); + let edit = match opt_bool(p, "edit") { + Ok(edit) => edit.unwrap_or(false), + Err(e) => return Some(Err(e)), + }; if edit || name.is_some() { crate::dialogs::symbol_options::open_edit(app, name.as_deref()) } else { @@ -899,9 +944,8 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), "ui.batchDialog" => { - let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; - let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; - crate::dialogs::action_batch::open(app, set, action) + let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0)); + index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action)) } crate::panels::actions::SAVE_SET => crate::panels::actions::save_set(app, p), crate::panels::actions::LOAD_SET => crate::panels::actions::load_set(app, p), @@ -934,7 +978,11 @@ fn run_ui_part12(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result // File → Export Selection…: the selection becomes one asset (`multiple: true`: one per // object), shown checked on Export for Screens' Assets tab. "file.exportSelection" => { - app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { + let multiple = match opt_bool(p, "multiple") { + Ok(multiple) => multiple.unwrap_or(false), + Err(e) => return Some(Err(e)), + }; + app.run("assets.add", json!({ "multiple": multiple })).map(|r| { let ids: Vec<u64> = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); json!({ "assets": ids }) diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..7ced9eb156 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1265,6 +1265,19 @@ fails with no change. A description with a `…` entry takes more keys than it l object can carry several formats' options). A command that takes its own reply back (`document.setup`, `text.areaOptions`) says so the same way. +The app's own commands are not checked against their descriptions, but those listed here refuse a value of the wrong +type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (`mode`), `window.panel` (`toggle`, +`toggleGroup`), `file.revert` (`confirmed`), `file.close` (`index`), `ui.newSwatch` (`spot`, `global`), +`ui.symbolOptions` (`edit`), `ui.batchDialog` (`set`, `action`) and `file.exportSelection` (`multiple`). So do the +control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`, +`ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot +calls. Omitting a param or giving null keeps its default: + +```json +{"id": "view.setZoom", "params": {"zoom": "200"}} +→ `zoom` must be a number +``` + Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function key, the Batch dialog or `ui.action.play`), a key a command no longer reads, recorded by an older version, is ignored and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect From 123c9b5e7db6801e8b5c96015a31093fd9216d09 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:33:53 -0800 Subject: [PATCH 031/126] Brushes: built-in Bristle Brushes library with one bristle brush for each tip shape Window > Brush Libraries > Bristle Brushes holds ten original bristle brushes generated in code, one for each tip shape: Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender and Grass Fan. Their names and values are VectorCraft's own, and every value lies inside the range the bristle brush keeps. A test checks the ranges, that every tip shape is covered, that no name matches a default brush or another library's brush, that adding the library is one undo step and a second add adds nothing, and that a saved library reopens with the same brushes. The library and brush names have rows in every catalog. docs/mcp.md lists the library. --- crates/engine/src/cmd/brushlib.rs | 30 ++++++- crates/engine/src/tests/brushlib.rs | 78 ++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 11 +++ crates/ui-egui/src/i18n/bs.tsv | 11 +++ crates/ui-egui/src/i18n/ca.tsv | 11 +++ crates/ui-egui/src/i18n/cs.tsv | 11 +++ crates/ui-egui/src/i18n/da.tsv | 11 +++ crates/ui-egui/src/i18n/de.tsv | 11 +++ crates/ui-egui/src/i18n/el.tsv | 11 +++ crates/ui-egui/src/i18n/es.tsv | 11 +++ crates/ui-egui/src/i18n/fi.tsv | 11 +++ crates/ui-egui/src/i18n/fr.tsv | 11 +++ crates/ui-egui/src/i18n/hi.tsv | 11 +++ crates/ui-egui/src/i18n/hu.tsv | 11 +++ crates/ui-egui/src/i18n/id.tsv | 11 +++ crates/ui-egui/src/i18n/it.tsv | 11 +++ crates/ui-egui/src/i18n/ja.tsv | 11 +++ crates/ui-egui/src/i18n/ka.tsv | 11 +++ crates/ui-egui/src/i18n/ko.tsv | 11 +++ crates/ui-egui/src/i18n/nl.tsv | 11 +++ crates/ui-egui/src/i18n/pl.tsv | 11 +++ crates/ui-egui/src/i18n/pt-br.tsv | 11 +++ crates/ui-egui/src/i18n/ru.tsv | 11 +++ crates/ui-egui/src/i18n/sv.tsv | 11 +++ crates/ui-egui/src/i18n/tr.tsv | 11 +++ crates/ui-egui/src/i18n/uk.tsv | 11 +++ crates/ui-egui/src/i18n/vi.tsv | 11 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 11 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 11 +++ .../src/panels/tests_brush_libraries.rs | 1 + docs/gaps.md | 6 +- docs/mcp.md | 4 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 9 ++- 34 files changed, 413 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 8de9dd5c84..163518e591 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -11,7 +11,9 @@ use std::collections::HashSet; use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; -use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_brush::{ + self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation, +}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes}; @@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary { pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, + BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, @@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary { built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes) } +/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity +/// and stiffness (%). +fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush { + Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) } +} + +/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for +/// blending and texture. +fn bristle_brushes() -> BrushLibrary { + use BristleShape::*; + let brushes = vec![ + bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]), + bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]), + bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]), + bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]), + bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]), + bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]), + bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]), + bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]), + bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]), + bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]), + ]; + built("Bristle Brushes", Document::new(100.0, 100.0), brushes) +} + fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush { let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() }); Brush { name: name.into(), kind } diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..243ea564f4 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -117,13 +117,13 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[4]["count"].as_u64(), Some(4)); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -132,6 +132,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { for (id, kind) in [ ("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), + ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), ("game-trails", "art"), @@ -165,6 +166,77 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value +/// outside would be clamped without notice), names no other library or default brush uses; added +/// in one step, not twice, and saved as a library they reopen unchanged. +#[test] +fn the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges() { + use vectorcraft_brush::{Bristle, BristleShape}; + let mut s = session(); + let (_, lib) = cmd::brushlib::library(&s, "bristle-brushes").unwrap(); + assert_eq!(lib.name, "Bristle Brushes"); + let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert!((8..=10).contains(&names.len()), "{names:?}"); + let within = |v: f64, (lo, hi): (f64, f64)| (lo..=hi).contains(&v); + let mut shapes = vec![]; + for b in &lib.brushes { + let BrushKind::Bristle(x) = &b.kind else { panic!("{} is not a bristle brush", b.name) }; + assert!(within(x.size, Bristle::SIZE_RANGE) && within(x.length, Bristle::LENGTH_RANGE), "{}", b.name); + for v in [x.density, x.thickness, x.opacity, x.stiffness] { + assert!(within(v, Bristle::PERCENT_RANGE), "{}: {v}", b.name); + } + let mut kept = b.clone(); + kept.sanitize(); + assert_eq!(&kept, b, "sanitizing changes {}", b.name); + assert!(b.name.chars().count() <= Bristle::MAX_NAME_CHARS, "{}", b.name); + if !shapes.contains(&x.shape) { + shapes.push(x.shape); + } + } + let every = [ + BristleShape::RoundPoint, + BristleShape::FlatPoint, + BristleShape::RoundBlunt, + BristleShape::FlatBlunt, + BristleShape::RoundCurve, + BristleShape::FlatCurve, + BristleShape::RoundAngle, + BristleShape::FlatAngle, + BristleShape::RoundFan, + BristleShape::FlatFan, + ]; + assert!(every.iter().all(|x| shapes.contains(x)), "{shapes:?}"); + // Names of its own: no default brush's, no other built-in library's, none twice. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec<String> = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "bristle-brushes") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>()) + .collect(); + for (i, n) in names.iter().enumerate() { + assert!(!defaults.contains(&n.as_str()) && !others.contains(n) && !names[..i].contains(n), "{n}"); + } + + // Added to a document in one undo step; adding them again adds nothing. + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Bristle Brushes"})); + assert_eq!(strs(&r["added"]), names.iter().map(String::as_str).collect::<Vec<_>>()); + assert_eq!(undo_len(&s), before.0 + 1); + assert_eq!(brushes(&s), [before.1.clone(), names.clone()].concat()); + for b in &lib.brushes { + assert_eq!(&brush(&s, &b.name), b); + } + let again = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "bristle-brushes"})); + assert_eq!((strs(&r["added"]).len(), strs(&r["existing"]).len(), undo_len(&s)), (0, names.len(), again)); + // Saved as a library and loaded back: the same brushes, in the same order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Bristles" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Bristles.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.brushes, lib.brushes); +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..a58b3d6ac8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5974,6 +5974,17 @@ axis Y Axis Y ос Charge Wave Вълна на заряд Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци + Bristle Brushes Четки с косъм + Fine Liner Тънък контур + Pointed Flat Заострена плоска + Stubby Round Къса кръгла + Dry Scrub Сухо търкане + Soft Dome Мек купол + Filbert Плоска овална + Angled Round Скосена кръгла + Angled Flat Скосена плоска + Fan Blender Ветрило за смесване + Grass Fan Ветрило за трева Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури Save Brush Library… Запази библиотека с четки… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..68547afeb7 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6024,6 +6024,17 @@ axis Y Axis Y osa Charge Wave Talas naboja Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi + Bristle Brushes Četkice s čekinjama + Fine Liner Tanka linija + Pointed Flat Šiljata ravna + Stubby Round Kratka okrugla + Dry Scrub Suho trljanje + Soft Dome Meka kupola + Filbert Ovalna ravna + Angled Round Kosa okrugla + Angled Flat Kosa ravna + Fan Blender Lepeza za stapanje + Grass Fan Lepeza za travu Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici Save Brush Library… Sačuvaj biblioteku četkica… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..4efbeeb129 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -607,6 +607,17 @@ antiAlias Sharp Nítid Calligraphic Brush Pinzell cal·ligràfic Calligraphic Brush Options Opcions del pinzell cal·ligràfic Calligraphic Nibs Plomes cal·ligràfiques + Bristle Brushes Pinzells de cerres + Fine Liner Traç fi + Pointed Flat Pla punxegut + Stubby Round Rodó curt + Dry Scrub Fregat sec + Soft Dome Cúpula suau + Filbert Llengua de gat + Angled Round Rodó bisellat + Angled Flat Pla bisellat + Fan Blender Ventall difuminador + Grass Fan Ventall d'herba Can't export “{name}”. No es pot exportar “{name}”. Can't open “{name}”. No es pot obrir “{name}”. Can't print these settings: see the Summary No es poden imprimir aquests paràmetres: consulta el resum diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..b1360ad04a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6209,6 +6209,17 @@ axis Y Axis osa Y Charge Wave Nabíjecí vlna Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera + Bristle Brushes Štětinové štětce + Fine Liner Jemná linka + Pointed Flat Špičatý plochý + Stubby Round Krátký kulatý + Dry Scrub Suché drhnutí + Soft Dome Měkká kopule + Filbert Plochý oválný + Angled Round Šikmý kulatý + Angled Flat Šikmý plochý + Fan Blender Vějíř na prolínání + Grass Fan Vějíř na trávu Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary Save Brush Library… Uložit knihovnu štětců… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..0c70f895e6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5989,6 +5989,17 @@ axis Y Axis Y-akse Charge Wave Opladningsbølge Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne + Bristle Brushes Børstepensler + Fine Liner Fin streg + Pointed Flat Spids flad + Stubby Round Kort rund + Dry Scrub Tør skrubning + Soft Dome Blød kuppel + Filbert Oval flad + Angled Round Skrå rund + Angled Flat Skrå flad + Fan Blender Blendevifte + Grass Fan Græsvifte Pattern Borders Mønsterborter Scatter Shapes Spredte former Save Brush Library… Gem penselbibliotek… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..ba3ba0d4ca 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5792,6 +5792,17 @@ axis Y Axis Y-Achse Charge Wave Ladewelle Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn + Bristle Brushes Borstenpinsel-Set + Fine Liner Feiner Liner + Pointed Flat Spitzer Flachpinsel + Stubby Round Kurzer Rundpinsel + Dry Scrub Trockenes Schrubben + Soft Dome Weiche Kuppel + Filbert Katzenzunge + Angled Round Schräger Rundpinsel + Angled Flat Schräger Flachpinsel + Fan Blender Fächer zum Verblenden + Grass Fan Grasfächer Pattern Borders Musterbordüren Scatter Shapes Streuformen Save Brush Library… Pinsel-Bibliothek speichern… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..b0b3f807b5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6160,6 +6160,17 @@ axis Y Axis Άξονας Y Charge Wave Κύμα φόρτισης Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες + Bristle Brushes Πινέλα με τρίχες + Fine Liner Λεπτή γραμμή + Pointed Flat Μυτερό πλακέ + Stubby Round Κοντό στρογγυλό + Dry Scrub Ξηρό τρίψιμο + Soft Dome Μαλακός θόλος + Filbert Γλώσσα γάτας + Angled Round Λοξό στρογγυλό + Angled Flat Λοξό πλακέ + Fan Blender Βεντάλια ανάμειξης + Grass Fan Βεντάλια για γρασίδι Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..5f0aa42ee0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6151,6 +6151,17 @@ axis Y Axis Eje Y Charge Wave Onda de carga Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas + Bristle Brushes Pinceles de cerdas + Fine Liner Delineador fino + Pointed Flat Plano puntiagudo + Stubby Round Redondo corto + Dry Scrub Frotado seco + Soft Dome Cúpula suave + Filbert Lengua de gato + Angled Round Redondo biselado + Angled Flat Plano biselado + Fan Blender Abanico difuminador + Grass Fan Abanico de hierba Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión Save Brush Library… Guardar biblioteca de pinceles… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..1da1ad5a25 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6239,6 +6239,17 @@ axis Y Axis Y-akseli Charge Wave Latausaalto Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet + Bristle Brushes Harjassiveltimet + Fine Liner Ohut viiva + Pointed Flat Suippo litteä + Stubby Round Lyhyt pyöreä + Dry Scrub Kuiva hankaus + Soft Dome Pehmeä kupu + Filbert Kissankieli + Angled Round Vino pyöreä + Angled Flat Vino litteä + Fan Blender Sekoitusviuhka + Grass Fan Ruohoviuhka Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot Save Brush Library… Tallenna sivellinkirjasto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..490fc5cb70 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6204,6 +6204,17 @@ axis Y Axis Axe Y Charge Wave Vague de charge Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques + Bristle Brushes Pinceaux à soies + Fine Liner Traceur fin + Pointed Flat Plat pointu + Stubby Round Rond court + Dry Scrub Frottis sec + Soft Dome Dôme doux + Filbert Langue de chat + Angled Round Rond biseauté + Angled Flat Plat biseauté + Fan Blender Éventail estompeur + Grass Fan Éventail à herbe Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion Save Brush Library… Enregistrer la bibliothèque de formes… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..52e6e4be81 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -689,6 +689,17 @@ antiAlias Sharp शार्प Calligraphic Brush कैलिग्राफिक ब्रश Calligraphic Brush Options कैलिग्राफिक ब्रश ऑप्शन्स Calligraphic Nibs कैलिग्राफिक निब्स + Bristle Brushes ब्रिसल ब्रश संग्रह + Fine Liner बारीक लाइनर + Pointed Flat नुकीला फ्लैट + Stubby Round छोटा गोल + Dry Scrub सूखी रगड़ + Soft Dome मुलायम गुंबद + Filbert फिल्बर्ट + Angled Round तिरछा गोल + Angled Flat तिरछा फ्लैट + Fan Blender मिश्रण पंखा + Grass Fan घास पंखा Can't export “{name}”. "{name}" निर्यात नहीं कर सकते। Can't open “{name}”. "{name}" नहीं खोल सकते हैं। Can't print these settings: see the Summary इन सेटिंग्स को मुद्रित नहीं कर सकते: सारांश देखें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..01ced9e05f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6192,6 +6192,17 @@ axis Y Axis Y tengely Charge Wave Töltéshullám Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek + Bristle Brushes Sörteecsetek + Fine Liner Finom kontúr + Pointed Flat Hegyes lapos + Stubby Round Rövid kerek + Dry Scrub Száraz dörzsölés + Soft Dome Lágy kupola + Filbert Macskanyelv + Angled Round Ferde kerek + Angled Flat Ferde lapos + Fan Blender Elmosó legyező + Grass Fan Fűlegyező Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok Save Brush Library… Ecsetkönyvtár mentése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..70ceb67db9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5989,6 +5989,17 @@ axis Y Axis Sumbu Y Charge Wave Gelombang Muatan Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi + Bristle Brushes Kuas-kuas bristle + Fine Liner Garis halus + Pointed Flat Pipih runcing + Stubby Round Bulat pendek + Dry Scrub Gosok kering + Soft Dome Kubah lembut + Filbert Pipih oval + Angled Round Bulat miring + Angled Flat Pipih miring + Fan Blender Kipas pembaur + Grass Fan Kipas rumput Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar Save Brush Library… Simpan pustaka kuas… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..e03211a3bb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5826,6 +5826,17 @@ axis Y Axis Asse Y Charge Wave Onda di carica Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici + Bristle Brushes Pennelli a setole + Fine Liner Tratto fine + Pointed Flat Piatto appuntito + Stubby Round Tondo corto + Dry Scrub Strofinato secco + Soft Dome Cupola morbida + Filbert Lingua di gatto + Angled Round Tondo obliquo + Angled Flat Piatto obliquo + Fan Blender Ventaglio sfumino + Grass Fan Ventaglio per erba Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione Save Brush Library… Salva libreria pennelli… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..e56c64967d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5891,6 +5891,17 @@ axis Y Axis Y 軸 Charge Wave チャージウェーブ Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 + Bristle Brushes 剛毛ブラシ集 + Fine Liner 細線ライナー + Pointed Flat 先細平筆 + Stubby Round 短丸筆 + Dry Scrub ドライスクラブ + Soft Dome ソフトドーム + Filbert フィルバート + Angled Round 斜め丸筆 + Angled Flat 斜め平筆 + Fan Blender ぼかし扇筆 + Grass Fan 草描き扇筆 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ Save Brush Library… ブラシライブラリを保存… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..a344ab813f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6160,6 +6160,17 @@ axis Y Axis Y ღერძი Charge Wave მუხტის ტალღა Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები + Bristle Brushes ჯაგრის ფუნჯები + Fine Liner წვრილი ხაზი + Pointed Flat წაწვეტებული ბრტყელი + Stubby Round მოკლე მრგვალი + Dry Scrub მშრალი ხეხვა + Soft Dome რბილი გუმბათი + Filbert ოვალური ბრტყელი + Angled Round დახრილი მრგვალი + Angled Flat დახრილი ბრტყელი + Fan Blender შერევის მარაო + Grass Fan ბალახის მარაო Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..29e265aa59 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5787,6 +5787,17 @@ axis Y Axis Y축 Charge Wave 차지 웨이브 Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 + Bristle Brushes 강모 브러시 모음 + Fine Liner 가는 라이너 + Pointed Flat 뾰족한 평붓 + Stubby Round 짧은 둥근 붓 + Dry Scrub 마른 문지르기 + Soft Dome 부드러운 돔 + Filbert 필버트 + Angled Round 사선 둥근 붓 + Angled Flat 사선 평붓 + Fan Blender 블렌딩 부채붓 + Grass Fan 잔디 부채붓 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 Save Brush Library… 브러시 라이브러리 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..417b9d5d1f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6040,6 +6040,17 @@ axis Y Axis Y-as Charge Wave Laadgolf Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten + Bristle Brushes Borstelpenselen + Fine Liner Fijne liner + Pointed Flat Spitse platte + Stubby Round Korte ronde + Dry Scrub Droog schrobben + Soft Dome Zachte koepel + Filbert Kattentong + Angled Round Schuine ronde + Angled Flat Schuine platte + Fan Blender Vervloeiwaaier + Grass Fan Graswaaier Pattern Borders Patroonranden Scatter Shapes Strooivormen Save Brush Library… Penseelbibliotheek opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..403b4b338a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6063,6 +6063,17 @@ axis Y Axis Oś Y Charge Wave Fala ładunku Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne + Bristle Brushes Pędzle włosiankowe + Fine Liner Cienka linia + Pointed Flat Szpiczasty płaski + Stubby Round Krótki okrągły + Dry Scrub Suche szorowanie + Soft Dome Miękka kopuła + Filbert Koci język + Angled Round Skośny okrągły + Angled Flat Skośny płaski + Fan Blender Wachlarz do mieszania + Grass Fan Wachlarz do trawy Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone Save Brush Library… Zapisz bibliotekę pędzli… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..bcb1738d3c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6124,6 +6124,17 @@ axis Y Axis Eixo Y Charge Wave Onda de carga Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas + Bristle Brushes Pincéis de Cerdas + Fine Liner Traço Fino + Pointed Flat Chato Pontudo + Stubby Round Redondo Curto + Dry Scrub Esfregado Seco + Soft Dome Cúpula Suave + Filbert Língua de Gato + Angled Round Redondo Chanfrado + Angled Flat Chato Chanfrado + Fan Blender Leque Esfumador + Grass Fan Leque de Grama Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão Save Brush Library… Salvar Biblioteca de Pincéis… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..7b0363fd4f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5820,6 +5820,17 @@ axis Y Axis Ось Y Charge Wave Волна заряда Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья + Bristle Brushes Щетинные кисти + Fine Liner Тонкий лайнер + Pointed Flat Заострённая плоская + Stubby Round Короткая круглая + Dry Scrub Сухое растирание + Soft Dome Мягкий купол + Filbert Кошачий язык + Angled Round Скошенная круглая + Angled Flat Скошенная плоская + Fan Blender Веер для растушёвки + Grass Fan Веер для травы Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры Save Brush Library… Сохранить библиотеку кистей… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..6de152e9c8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6232,6 +6232,17 @@ axis Y Axis Y-axel Charge Wave Laddningsvåg Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar + Bristle Brushes Borstpenslar + Fine Liner Fin linje + Pointed Flat Spetsig platt + Stubby Round Kort rund + Dry Scrub Torr skrubbning + Soft Dome Mjuk kupol + Filbert Oval platt + Angled Round Sned rund + Angled Flat Sned platt + Fan Blender Blandande solfjäder + Grass Fan Grässolfjäder Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer Save Brush Library… Spara penselbibliotek… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..c77b19c0f1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6165,6 +6165,17 @@ axis Y Axis Y ekseni Charge Wave Şarj Dalgası Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları + Bristle Brushes Kıl Fırçalar + Fine Liner İnce Çizgi + Pointed Flat Sivri Düz + Stubby Round Kısa Yuvarlak + Dry Scrub Kuru Ovma + Soft Dome Yumuşak Kubbe + Filbert Kedi Dili + Angled Round Eğik Yuvarlak + Angled Flat Eğik Düz + Fan Blender Harmanlama Yelpazesi + Grass Fan Çim Yelpazesi Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri Save Brush Library… Fırça Kitaplığını Kaydet… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..edd8e376e9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5806,6 +5806,17 @@ axis Y Axis Вісь Y Charge Wave Хвиля заряду Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера + Bristle Brushes Щетинні пензлі + Fine Liner Тонкий лайнер + Pointed Flat Загострений плаский + Stubby Round Короткий круглий + Dry Scrub Сухе розтирання + Soft Dome М'який купол + Filbert Котячий язик + Angled Round Скошений круглий + Angled Flat Скошений плаский + Fan Blender Віяло для розтушовування + Grass Fan Віяло для трави Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури Save Brush Library… Зберегти бібліотеку пензлів… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..4800dc0d66 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5807,6 +5807,17 @@ axis Y Axis Trục Y Charge Wave Sóng nạp Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp + Bristle Brushes Bộ cọ lông + Fine Liner Nét mảnh + Pointed Flat Cọ dẹt nhọn + Stubby Round Cọ tròn ngắn + Dry Scrub Chà khô + Soft Dome Vòm mềm + Filbert Cọ lưỡi mèo + Angled Round Cọ tròn xiên + Angled Flat Cọ dẹt xiên + Fan Blender Cọ quạt hòa trộn + Grass Fan Cọ quạt vẽ cỏ Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán Save Brush Library… Lưu thư viện cọ… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..8b354491fe 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6047,6 +6047,17 @@ axis Y Axis Y 轴 Charge Wave 充能波 Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 + Bristle Brushes 鬃毛画笔集 + Fine Liner 细勾线笔 + Pointed Flat 尖头平笔 + Stubby Round 短圆笔 + Dry Scrub 干擦 + Soft Dome 柔和圆顶 + Filbert 榛形笔 + Angled Round 斜圆笔 + Angled Flat 斜平笔 + Fan Blender 混色扇形笔 + Grass Fan 画草扇形笔 Pattern Borders 图案边框 Scatter Shapes 散布形状 Save Brush Library… 保存画笔库… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..367e54e7aa 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5844,6 +5844,17 @@ axis Y Axis Y 軸 Charge Wave 充能波 Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 + Bristle Brushes 鬃毛筆刷集 + Fine Liner 細勾線筆 + Pointed Flat 尖頭平筆 + Stubby Round 短圓筆 + Dry Scrub 乾擦 + Soft Dome 柔和圓頂 + Filbert 榛形筆 + Angled Round 斜圓筆 + Angled Flat 斜平筆 + Fan Blender 混色扇形筆 + Grass Fan 畫草扇形筆 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 Save Brush Library… 儲存筆刷資料庫… diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..4d8959f262 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -132,6 +132,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { [ json!("art-ribbons"), json!("calligraphic-nibs"), + json!("bristle-brushes"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..0b7d52275f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -630,8 +630,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -649,7 +649,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..17efd02c69 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3223,8 +3223,8 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b55740614..7c474a0a2f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), 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 (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), 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 ↑/↓, show recently applied families first (#3515), 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 (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), 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; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); 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; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; 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; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows 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; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| 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; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Bristle Brushes, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; 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 with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), 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 and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..361deecfcc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -218,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Bristle Brushes, Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Bristle Brushes, Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -823,7 +823,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From 172e2923c0fbfb940ca856f4e880755ab6070821 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:33:55 -0800 Subject: [PATCH 032/126] MCP: regenerate the UI catalog --- crates/mcp/src/ui_catalog.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index bf6d28cddb..9ea9333811 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -748,6 +748,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", ), + ( + "help.copyTroubleshootingInfo", + "Copy Troubleshooting Info", + "", + "{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions", From 68cbf041a4b67212341379ee8850bc4e861ef9b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:50:00 -0800 Subject: [PATCH 033/126] MCP: replies hide each sent path the same way whatever the outcome Each hidden id or path is replaced once, chosen from the reply as given, so a hidden path inside another library's shown id is not replaced again, and a readable id written with %XX escapes keeps the hidden path it begins with. A path quoted with {:?} is matched with its quotes and control characters escaped. A path outside the read roots that the request sent is shown by its file name in a reply that succeeded as in one that failed, and a path param counts with a relative path too. A relative path is not replaced where it ends a longer path. Without a read root, a file: URL that names another host stays in ui.inspect's recorded steps. --- crates/ui-egui/src/control.rs | 216 +++++++++++++------------ crates/ui-egui/src/control/tests.rs | 83 +++++++++- crates/ui-egui/src/tests/automation.rs | 5 +- docs/mcp.md | 8 +- 4 files changed, 200 insertions(+), 112 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a198810b41..c1b7350527 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -441,13 +441,19 @@ impl Hiding { } /// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths -/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>` -/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file -/// name also for a hidden library shown as `loaded/<file name> (2)`, so that it reads the same whether -/// or not a library was loaded from one of them. +/// ([`unreadable_path_like`]) or held by a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id +/// (`loaded/<path>`) with `loaded/<file name>` shown. A reply hides them as it hides a hidden library's +/// path, with that file name also for a hidden library shown as `loaded/<file name> (2)`, so that it +/// reads the same whether or not a library was loaded from one of them. fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { - fn add(s: &str, out: &mut Vec<(String, String)>) { - if !unreadable_path_like(s) { + collect_sent_paths(v, false, out); +} + +/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set. +fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) { + fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) && !(path_param && !readable_value(s)) { return; } // A URL that names another host as the path after its scheme (`//host/share/…`). @@ -463,12 +469,12 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { } } match v { - Value::String(s) => add(s, out), - Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::String(s) => add(s, path_param, out), + Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)), Value::Object(fields) => { for (key, item) in fields { - add(key, out); - sent_paths(item, out); + add(key, false, out); + collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); } } _ => {} @@ -510,7 +516,7 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { - let pairs = hidden_text(&hiding.hidden); + let pairs = hidden_text(hiding); if pairs.is_empty() { return; } @@ -518,16 +524,22 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) { } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each `\` -/// doubled (a path quoted with `{:?}`). -fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { +/// path in it with the file name shown, the path written with either separator too, and as `{:?}` +/// quotes it (each `\`, quote and control character escaped). +fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { + let quoted = |s: &str| { + let q = format!("{s:?}"); + q[1..q.len() - 1].to_string() + }; let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in ids { + for (id, shown) in &hiding.hidden { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); - let back = path.replace('/', "\\"); - for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { - for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); + let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); + let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string())); + for (path, name) in plain.into_iter().chain(debug) { + for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { if !pairs.iter().any(|(f, _)| *f == from) { pairs.push((from, to)); } @@ -596,46 +608,70 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence /// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written -/// with `%XX` escapes (in a `file:` URL) is -/// replaced too; the escapes around it stay. `None` when nothing changed. +/// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence +/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already +/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again. +/// With the rewrite's `fold`, the ids and paths as written are found first, so that two libraries whose +/// paths differ only in letter case keep their own ids, and then the ones written in another case. +/// `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { - let replaced = replace_plain_text(s, r); - let text = replaced.as_deref().unwrap_or(s); - if !text.contains('%') { - return replaced; - } - let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; - if decoded == text { - return replaced; - } - // The spans of `text` to replace, found in the decoded text, longest `from` first. - let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); - let mut taken: Vec<(usize, usize)> = Vec::new(); - for (from, to) in &r.pairs { - for at in occurrences(&decoded, from, r.fold) { - let end = at + from.len(); - let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && !begins_known_id(&decoded, at, end, r) { - taken.push((at, end)); - cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; + let mut found = Found::default(); + for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { + for (from, to) in &r.pairs { + if let Some((text, spans)) = &decoded { + found.claim(text, Some(spans.as_slice()), from, to, fold, r); } + found.claim(s, None, from, to, fold, r); } } - if cuts.is_empty() { - return replaced; + if found.cuts.is_empty() { + return None; } + let mut cuts = found.cuts; cuts.sort_unstable_by_key(|&(start, _, _)| start); - let mut out = String::with_capacity(text.len()); + let mut out = String::with_capacity(s.len()); let mut copied = 0; for (start, stop, to) in cuts { - out.push_str(&text[copied..start]); + out.push_str(&s[copied..start]); out.push_str(to); copied = stop; } - out.push_str(&text[copied..]); + out.push_str(&s[copied..]); Some(out) } +/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has +/// taken: the ones it replaces and the loaded library ids it keeps. +#[derive(Default)] +struct Found<'a> { + cuts: Vec<(usize, usize, &'a str)>, + taken: Vec<(usize, usize)>, +} + +impl<'a> Found<'a> { + /// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`, + /// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans` + /// gives the range of the original text each byte of a decoded `text` was read from. + fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) { + let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1)); + for at in occurrences(text, from, fold) { + let end = at + from.len(); + let (start, stop) = span(at, end); + if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) { + continue; + } + match begins_known_id(text, at, end, r) { + Some(len) => self.taken.push(span(at, at + len)), + None => { + self.taken.push((start, stop)); + self.cuts.push((start, stop, to)); + } + } + } + } +} + /// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` /// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { @@ -679,44 +715,18 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { Some((decoded, decoded_spans)) } -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] -/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written -/// are replaced first, so that two libraries whose paths differ only in letter case keep their own -/// ids, and then the ones written in another case. `None` when nothing changed. -fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> { - let exact = replace_matches(s, r, false); - if !r.fold { - return exact; - } - replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) -} - -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case -/// of ASCII letters with `fold`. `None` when nothing changed. -fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { - let mut out: Option<String> = None; - for (from, to) in &r.pairs { - let text = out.as_deref().unwrap_or(s); - let found = occurrences(text, from, fold); - if found.is_empty() { - continue; - } - let mut next = String::with_capacity(text.len()); - let mut copied = 0; - for at in found { - let end = at + from.len(); - if !begins_known_id(text, at, end, r) { - next.push_str(&text[copied..at]); - next.push_str(to); - copied = end; - } - } - if copied > 0 { - next.push_str(&text[copied..]); - out = Some(next); - } - } - out +/// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at +/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a +/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path +/// `data/icon.svg`; `./data/icon.svg` does. +fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { + let absolute = + from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + let before = &text[..at]; + !absolute + && !before.ends_with("./") + && !before.ends_with(".\\") + && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -727,17 +737,18 @@ fn continues_path(rest: &str) -> bool { /// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written /// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops -/// continuing a path ([`continues_path`])? +/// continuing a path ([`continues_path`])? The length of that id as written, when it does. /// A readable library's id that begins with a hidden one stays, also with a space after the hidden /// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. -fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usize> { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); - r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + r.hiding.readable.iter().filter(|known| !dotted(known)).find_map(|known| { let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; - id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + let begins = id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]); + begins.then_some(id.len()) }) } @@ -826,11 +837,11 @@ fn readable(path: &str) -> bool { } /// Is `s` a path the request may not read, written as an absolute path (either separator, with or -/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is -/// never readable. +/// without a drive) or a `file:` URL ([`file_url_path`])? Under a read root, a `file:` URL that names +/// another host is never readable. fn unreadable_absolute(s: &str) -> bool { match file_url_path(s) { - Some(path) => !path.is_some_and(|p| readable(&p)), + Some(path) => !readable_url_path(path), None => { let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); absolute && !readable(s) @@ -854,11 +865,20 @@ fn unreadable_path_like(s: &str) -> bool { /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { - Some(path) => path.is_some_and(|p| readable(&p)), + Some(path) => readable_url_path(path), None => readable(s), } } +/// May the request read the local path a `file:` URL names ([`file_url_path`])? A URL that names +/// another host may be read only without a read root in force. +fn readable_url_path(path: Option<String>) -> bool { + match path { + Some(path) => readable(&path), + None => vectorcraft_engine::file_access::current().is_none(), + } +} + /// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` /// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). /// `file:///C:/x` names `C:/x`. @@ -1026,8 +1046,8 @@ const DOCKING_ACTION: &str = "action"; /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The -/// error of a failed request also hides the paths outside the read root that the request sent -/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// reply also shows each path outside the read root that the request sent by its file name, whether it +/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path /// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent /// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id /// or path. @@ -1062,19 +1082,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Outcome::Done(reply) } -/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths -/// outside the read root that `sent`, the request's params, holds ([`handle`]). +/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that +/// `sent`, the request's params, holds ([`handle`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { if !holds_a_separator(reply) { return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); - if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut paths = Vec::new(); - sent_paths(sent, &mut paths); - hiding.set_hidden(paths, cfg!(windows)); - } + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index efce2e7a47..9b159e2b7c 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -46,7 +46,7 @@ fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { #[test] fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); - let data = "QUJDassets/iconREVGxQQ=="; + let data = "QUJ+assets/iconREVGxQQ=="; let mut reply = json!({"ok": true, "result": { "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], "status": "from assets/icon", @@ -61,7 +61,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { assert_eq!(reply["result"]["status"], "from icon", "{reply}"); assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); - assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}"); } /// A hidden path followed by more text stays only when the longer text is another loaded library's @@ -96,19 +96,19 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { fn a_reply_hides_a_path_in_any_case_and_escaped() { let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); - let pairs = super::hidden_text(&hidden.hidden); + let pairs = super::hidden_text(&hidden); let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; super::replace_in_value(&mut reply, &rewrite); assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); // Two libraries whose paths differ only in letter case keep their own ids. let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true }); assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); @@ -132,8 +132,8 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { } /// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names -/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a -/// path inside the root are not. +/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden +/// by their file names; plain text and a path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -141,13 +141,14 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); + let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); let want = [ + pair("outside/h.svg", "h.svg"), pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), @@ -183,6 +184,72 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() { let _ = std::fs::remove_dir_all(base); } +/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps +/// it. +#[test] +fn a_url_naming_another_host_stays_without_a_read_root() { + assert!(!super::unreadable_absolute("file://host/share/a.svg")); + assert!(super::readable_value("file://host/share/a.svg")); + let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}); + let fields = params.as_object_mut().unwrap(); + super::leave_out_unreadable_params(fields); + assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]})); +} + +/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped. +#[test] +fn a_reply_hides_a_quoted_path_with_escaped_quotes() { + let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]); + let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")}); + hide_under_a_root("quoted", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}"); +} + +/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another +/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden +/// path it begins with. +#[test] +fn a_reply_replaces_each_hidden_text_once() { + let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "no loaded//b.svg"}); + hide_under_a_root("root-file", &mut reply, &hidden); + assert_eq!(reply["error"], "no loaded/b.svg", "{reply}"); + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]); + let mut reply = json!({"error": "file:///art/in%20work/a.svg"}); + hide_under_a_root("escaped-readable", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); +} + +/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a +/// longer path. +#[test] +fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { + let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); + let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); + hide_under_a_root("relative", &mut reply, &hidden); + assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); +} + +/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded +/// as in one that failed. +#[test] +fn a_sent_path_reads_the_same_whatever_the_outcome() { + let app = VectorcraftApp::new(Session::new(), Services::default()); + let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let sent = json!({"path": "/outside/b.svg"}); + let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}}); + let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default()); + super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default()); + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(done["result"]["path"], "b.svg", "{done}"); + assert_eq!(failed["error"], "can't read b.svg", "{failed}"); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 05811869ae..acfe7d9408 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -681,7 +681,8 @@ fn a_panic_reply_hides_a_library_hidden_before_the_request() { /// An error shows a path outside the read root that the request sent by its file name, whether or not /// a hidden library was loaded from that path, so that it reads the same either way. A path the -/// request sends does not show the app's own text that holds it, in an error or a result. +/// request sends does not show the app's own text that holds it, in an error or a result, and a result +/// shows that path by its file name as an error does. #[test] fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let (mut app, tx, base) = confined("client-echo"); @@ -698,7 +699,7 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); } let r = request(&mut app, &tx, "test.status", json!({"guess": path})); - assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + assert_eq!(r["result"], "Loaded b.svg", "{r}"); let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); let _ = std::fs::remove_dir_all(base); diff --git a/docs/mcp.md b/docs/mcp.md index 278d69e8d5..2e6c7dab21 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -157,9 +157,11 @@ the suite) keep the agent to the folders you give it: (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins - another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the - read roots that the request sent by its file name, whether or not a library was loaded from it, so that the - error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it and whether the + request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, + `destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a + longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an From 3e0d5a0aaf12f848f5e1ab3516f590bcf7017b6a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:59:31 -0800 Subject: [PATCH 034/126] MCP: a library saved with another platform's absolute path is hidden under a read root Outside Windows, a library path that starts with a drive or a backslash counts as outside the read roots, since the file access check would read it as a name in the working folder. --- crates/ui-egui/src/control.rs | 10 +++++++++- crates/ui-egui/src/control/tests.rs | 12 ++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c1b7350527..7a839f7240 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -365,7 +365,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { let path = &id["loaded/".len()..]; // Either separator: a path saved on another platform counts as a path here too. let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); - if name != path && !readable(path) { + if name != path && (written_elsewhere(path) || !readable(path)) { hidden.push((id, name)); } else { taken.insert(id.clone()); @@ -399,6 +399,14 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { Hiding { hidden, readable: readable_ids } } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? The file access check would read it as a name in the +/// working folder. +fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && vectorcraft_engine::file_access::current().is_some() +} + /// What a reply hides ([`hide_library_ids`]). #[derive(Debug, Default)] struct Hiding { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 9b159e2b7c..6436f77bf5 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -220,6 +220,18 @@ fn a_reply_replaces_each_hidden_text_once() { assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); } +/// A library saved with another platform's absolute path is hidden, also when the working folder is +/// inside the read root. +#[test] +fn a_library_saved_with_another_platforms_path_is_hidden() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let here = std::env::current_dir().unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); + let hiding = vectorcraft_engine::file_access::confine(roots.as_ref(), || super::unreadable_library_ids(&app)); + assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); +} + /// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a /// longer path. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 2e6c7dab21..aff277d4b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,7 +141,8 @@ the suite) keep the agent to the folders you give it: roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with - either separator counts as a path, also one saved on another platform. When another hidden library has the same + either separator counts as a path, also one saved on another platform, and outside Windows a path that starts + with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on: first the libraries loaded in the session (swatch, graphic style, symbol and brush libraries, each kind in the order it was loaded), then the library panels in their order, the From 17a51fc5911eb2786e964d457fbca3b05defafb0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:59:15 -0800 Subject: [PATCH 035/126] Data Recovery: a running app locks its area again when the lock file is removed While an app runs, its Data Recovery area's .lock file, or the whole area folder, can be removed by the user, a cleaner or another program. The app kept writing copies into an area that no lock held, and another app could lock that area and offer the copies as a crash's copies. Each recovery save and heartbeat now checks that the file at the area's .lock path is still the file the app locked. When the lock file or the folder is gone, or another file is in its place, the app makes the area again if it is gone, locks it again, and writes again the copies that went with the folder. A lock file that can't be read for another reason still counts as held. When another app holds the area by then, the app takes a new area and writes every copy there. The app does not offer or discard its own copies in the old area as a crash's copies. It removes each one when its document has a copy in the new area, or is saved, reverted or closed, and removes all of them when it quits with nothing unsaved. The app that holds the old area also removes them when it quits with nothing unsaved. A copy that was being written when the Data Recovery folder preference changed is now removed from the folder it was written to. Before, the document took that copy as its current one, and the copy stayed in the old folder after the document was saved or closed. --- crates/engine/src/cmd/recovery.rs | 181 +++++++++++++++-- crates/engine/src/tests/recovery.rs | 304 ++++++++++++++++++++++++++++ crates/ui-egui/src/recovery.rs | 4 +- 3 files changed, 469 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index b75066ec6a..bb7fbc6a5c 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -17,7 +17,9 @@ //! store can, an announcement that lasts as long as the app ([`RecoveryStore::announce`]: the //! browser's Web Locks). Only areas nobody holds any more (their app is gone) are offered, and //! while their copies are restored or discarded the area is held, so two apps launched together -//! never both take it. +//! never both take it. An area's lock file or folder can be removed while its app runs, so each +//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or +//! takes a new area. //! //! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so //! when unsure an area counts as held, and its app repairs what another app did meanwhile: each @@ -100,6 +102,12 @@ pub trait RecoveryStore: Send + Sync { fn lock(&self, _area: &str) -> std::result::Result<Lock, String> { Ok(Lock::Unsupported) } + /// Does `hold`, which [`RecoveryStore::lock`] gave for area `area`, still hold it? A lock on a + /// file or folder that was removed or replaced since holds nothing; when that can't be told now, + /// it does (default: it does). + fn still_held(&self, _area: &str, _hold: &(dyn std::any::Any + Send + Sync)) -> bool { + true + } /// Stores without locks: also hold area `area` in a way other apps can see that lasts exactly as /// long as this app runs, if the store has one (the browser's Web Locks) → it, until dropped. fn announce(&self, _area: &str) -> Option<Hold> { @@ -416,6 +424,16 @@ impl RecoveryStore for FolderStore { Self::create_area(&dir)?; Self::lock_area(&dir) } + fn still_held(&self, area: &str, hold: &(dyn std::any::Any + Send + Sync)) -> bool { + let (Some(file), Ok(dir)) = (hold.downcast_ref::<std::fs::File>(), self.area(area)) else { return true }; + let path = dir.join(LOCK_FILE); + // Only a lock file or folder that is gone, or another one in its place, ends the hold. One + // that can't be read now (a network folder that doesn't answer) still counts as held. + match std::fs::symlink_metadata(&path) { + Ok(_) => Self::own_area(&dir).is_ok() && Self::still_named(file, &path), + Err(e) => e.kind() != std::io::ErrorKind::NotFound, + } + } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty // (copies came back meanwhile) stays. The caller still holds the lock here. @@ -457,6 +475,10 @@ pub struct Recovery { /// heartbeat it wrote there: that one doesn't make them look running, a newer one does (their /// app was only paused and came back). adopted: BTreeMap<String, i64>, + /// Copies in an area this session had to leave (`keep_hold`), by document uid, with their + /// store: each stays until its document has a copy in the new area, or needs none. They are + /// still this session's: never offered or discarded as a crash's here. + left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>, } impl Recovery { @@ -526,14 +548,79 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>) /// Refresh this session's hold on its area (the app does this every [`HEARTBEAT_EVERY`] seconds, /// so also on its first frame after its timers were paused): its heartbeat in a store without -/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now). -/// Best effort: a missed beat only lets another app offer the copies once three intervals have -/// passed. +/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now), +/// and those whose only copy is in an area the session left (`keep_hold`). Best effort: a missed +/// beat only lets another app offer the copies once three intervals have passed. pub fn heartbeat(s: &mut Session) -> Vec<u64> { + let mut lost = keep_hold(s); if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) { let _ = beat(own.store.as_ref(), &own.area); } - repair(s) + // A copy that was being written when the session left its area finished there. A document + // that gets no copy now (a complex one) keeps the copy it has. + lost.extend( + s.recovery + .left + .keys() + .filter(|&&uid| s.documents().iter().any(|d| d.uid == uid && d.recovery.is_none() && skip_reason(s, d).is_none())) + .copied(), + ); + lost.extend(repair(s)); + lost.sort_unstable(); + lost.dedup(); + lost +} + +/// Check that this session's lock still holds its area (stores with locks). The area's lock file, +/// or the whole area, can be removed while the app runs (by the user, a cleaner, another program), +/// and then another app can lock the area and offer or delete this session's copies as a crash's. +/// The area is locked again, made again first if it went (its copies are then written again). +/// When another app holds it now, the session takes a new area, and each copy left in the old one +/// stays until its document has a copy in the new one. → the documents whose copies must be +/// written again. +fn keep_hold(s: &mut Session) -> Vec<u64> { + drop_left_done(s); + let Some(own) = &mut s.recovery.own else { return vec![] }; + if own.beats || own.hold.as_deref().is_none_or(|h| own.store.still_held(&own.area, h)) { + return vec![]; + } + let (store, area) = (own.store.clone(), own.area.clone()); + // The lock held is on a file the area no longer has, so it holds nothing: it goes first (a + // removed file this app still has open can keep a new lock file from being made). + own.hold = None; + if let Ok(Lock::Held(h)) = store.lock(&area) { + own.hold = Some(h); + return repair(s); + } + let mut lost = vec![]; + for st in &mut s.docs { + if let Some(c) = st.recovery.take() { + s.recovery.left.insert(st.uid, (store.clone(), c.file)); + lost.push(st.uid); + } + } + s.recovery.own = None; + // Best effort: when no area can be taken now, the next recovery save says why. + let _ = claim(s, &store); + lost +} + +/// The copies left in an area this session left whose documents were saved, reverted or closed +/// since go. +fn drop_left_done(s: &mut Session) { + let done: Vec<u64> = s.recovery.left.keys().filter(|&&uid| !s.documents().iter().any(|d| d.uid == uid && d.is_dirty())).copied().collect(); + for uid in done { + drop_left(s, uid); + } +} + +/// Document `uid`'s copy in an area this session left goes. Best effort: a copy that can't be +/// removed is offered once nobody holds that area, which loses nothing. +fn drop_left(s: &mut Session, uid: u64) { + if let Some((store, file)) = s.recovery.left.remove(&uid) { + let _ = store.remove(&format!("{file}{DOC_EXT}")); + let _ = store.remove(&format!("{file}{META_EXT}")); + } } /// Forget this session's copies that are missing from its area, so the next recovery save writes @@ -555,10 +642,13 @@ pub fn repair(s: &mut Session) -> Vec<u64> { /// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the /// session's lifetime (locked, or with a heartbeat and an announcement where the store has them). pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> { - if let Some(own) = &s.recovery.own { - if own.store.location() == store.location() { + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + // Documents whose copies went are left without one, so the caller writes them again. + keep_hold(s); + if let Some(own) = &s.recovery.own { return Ok(own.area.clone()); } + } else if s.recovery.own.is_some() { // The store moved (the preference names another folder): copies start again there. release(s); } @@ -588,6 +678,11 @@ pub fn release(s: &mut Session) { for st in &mut s.docs { st.recovery = None; } + // Copies left in an area this session had to leave go too. + let left: Vec<u64> = s.recovery.left.keys().copied().collect(); + for uid in left { + drop_left(s, uid); + } let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return }; let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { @@ -750,12 +845,16 @@ pub struct RecoveryJob { pub title: String, meta: Meta, save: SaveJob, + /// The store the copy goes to (the preference can name another folder before it is finished). + store: Arc<dyn RecoveryStore>, } impl RecoveryJob { /// Encode the snapshot and store it with its details and the images the area doesn't have - /// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`. - pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result<Value, String> { + /// yet, in the store the job was made for → `{file, title, bytes (the document's), imageBytes + /// (images written now)}`. + pub fn write(&self) -> std::result::Result<Value, String> { + let store = self.store.as_ref(); let doc = self.save.doc(); // A linked image keeps its preview, as a save writes it. let linked = doc.linked_only_images(); @@ -808,13 +907,35 @@ impl RecoveryJob { /// Once stored: the document (if still open) remembers the copy, so it isn't written again /// while unchanged and goes when the document is saved or closed. A document saved or closed - /// meanwhile has nothing left to recover: its copy goes at once. + /// meanwhile has nothing left to recover: its copy goes at once. So does a copy written in an + /// area the session left meanwhile (`keep_hold`), and its document gets a new one. pub fn finish(self, s: &mut Session) { let snapshot = self.save.snapshot().clone(); let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot }; + let area = self.file.split_once('/').map(|(a, _)| a); + let current = s.recovery.own.as_ref().is_some_and(|o| o.store.location() == self.store.location() && Some(o.area.as_str()) == area); match s.document_mut(self.uid).filter(|st| st.is_dirty()) { - Some(st) => st.recovery = Some(copy), - None => remove_copy(s, &self.file), + Some(st) if current => { + st.recovery = Some(copy); + drop_left(s, self.uid); + } + // Written in an area left meanwhile: kept, in place of the copy left there before, + // until the document has a copy in the new area. + Some(st) if st.recovery.is_none() => { + let before = s.recovery.left.insert(self.uid, (self.store.clone(), self.file.clone())); + // The copy left there before, unless this one was written over it. Best effort, + // as in `remove_copy`. + if let Some((store, file)) = before.filter(|(old, f)| old.location() != self.store.location() || *f != self.file) { + let _ = store.remove(&format!("{file}{DOC_EXT}")); + let _ = store.remove(&format!("{file}{META_EXT}")); + } + } + // Best effort, as in `remove_copy`. + _ => { + let _ = self.store.remove(&format!("{}{DOC_EXT}", self.file)); + let _ = self.store.remove(&format!("{}{META_EXT}", self.file)); + drop_left(s, self.uid); + } } } } @@ -867,6 +988,11 @@ fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String { /// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified /// documents that get none). pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<RecoveryJob>, Vec<Value>)> { + // A lost lock is taken again first: copies that go with it are written again, even of + // documents unchanged since. + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + keep_hold(s); + } // Copies another app removed are written again. repair(s); let skipped: Vec<Value> = s @@ -923,7 +1049,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco // Filled in as the copy is written. images: BTreeMap::new(), }; - out.push(RecoveryJob { uid: st.uid, file, title, meta, save }); + out.push(RecoveryJob { uid: st.uid, file, title, meta, save, store: store.clone() }); } Ok((out, skipped)) } @@ -954,7 +1080,7 @@ pub(crate) fn save_copies(s: &mut Session) -> Result<()> { return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into())); } for j in jobs { - j.write(store.as_ref()).map_err(EngineError::Other)?; + j.write().map_err(EngineError::Other)?; j.finish(s); } Ok(()) @@ -966,6 +1092,8 @@ pub fn forget(s: &mut Session, uid: u64) { if s.script_in_run { return; } + // So does a copy of it left in an area this session left. + drop_left(s, uid); let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return }; remove_copy(s, ©.file); } @@ -975,12 +1103,16 @@ pub fn forget_all(s: &mut Session) { release(s); } -/// The copies of documents with no unsaved changes left (undone back to the saved state) go. +/// The copies of documents with no unsaved changes left (undone back to the saved state) go, and +/// the copies left in an area this session left of documents closed since. pub fn forget_clean(s: &mut Session) { let uids: Vec<u64> = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect(); for uid in uids { forget(s, uid); } + if !s.script_in_run { + drop_left_done(s); + } } fn save(s: &mut Session, _: &Value) -> Result<Value> { @@ -988,7 +1120,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> { let (jobs, skipped) = jobs(s, &store)?; let mut saved = vec![]; for j in jobs { - saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?); + saved.push(j.write().map_err(EngineError::Other)?); j.finish(s); } forget_clean(s); @@ -1057,9 +1189,15 @@ fn open_copies(s: &Session) -> Vec<String> { s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect() } +/// The copies of documents open now in this session that are in an area it left, in `store`. +fn left_copies(s: &Session, store: &dyn RecoveryStore) -> Vec<String> { + s.recovery.left.values().filter(|(st, _)| st.location() == store.location()).map(|(_, f)| f.clone()).collect() +} + fn list(s: &mut Session, _: &Value) -> Result<Value> { let store = need_store(s, "file.recovery.list")?; let open = open_copies(s); + let left = left_copies(s, store.as_ref()); let copies = copies_in(&store.list().map_err(EngineError::Other)?); let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { @@ -1071,7 +1209,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> { let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, - "open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running, + "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } // A gone app's empty area is tidied away. @@ -1095,7 +1233,14 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> { return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)"))); } - let picked = |c: &str| file.is_none_or(|f| f == c); + // A copy this session left in an area it had to leave is still its own. + let left = left_copies(s, store); + if let Some(f) = file + && left.iter().any(|c| c == f) + { + return Err(bad(cmd, format!("`{f}` is the copy of a document open now"))); + } + let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); let mut out = vec![]; for area in areas { diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 4994a90728..acd9828141 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -682,6 +682,310 @@ fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() { let _ = std::fs::remove_dir_all(&dir); } +/// A session keeping its copies in folder `dir`, with one modified document whose copy is written. +fn saved_in(dir: &std::path::Path) -> (Session, String) { + let mut s = Session::new(); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + let area = s.recovery.own_area().unwrap().to_string(); + (s, area) +} + +/// A new app over folder `dir`, with nothing open. +#[cfg(unix)] +fn other_app(dir: &std::path::Path) -> Session { + let mut s = Session::new(); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + s +} + +/// The area's lock file was removed while its app runs: the next recovery save locks the area +/// again, so another app neither offers nor discards its copy. +#[cfg(unix)] +#[test] +fn a_recovery_save_locks_the_area_again_when_its_lock_file_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-relock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(a.recovery.own_area(), Some(area.as_str())); + let mut b = other_app(&dir); + assert!(offered(&mut b).is_empty()); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The whole area was removed while its app runs: the next recovery save makes it again, locked, +/// with the copy written again. +#[cfg(unix)] +#[test] +fn a_recovery_save_makes_the_area_again_when_it_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-remade-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_dir_all(dir.join(&area)).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another app locked the area after its lock file was removed: the next heartbeat takes a new +/// area, and the document is to be copied again there. Its copy in the old area stays until then. +#[cfg(unix)] +#[test] +fn an_area_another_app_locked_meanwhile_is_left_for_a_new_one() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-moved-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let store = FolderStore::new(&dir); + let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") }; + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert!(a.doc().unwrap().recovery.is_none()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file(), "kept until the new copy is written"); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists(), "the copy left there goes"); + assert!(matches!(store.lock(&mine), Ok(Lock::Busy))); + drop(other); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy written in the background into the area the session left meanwhile is not the +/// document's copy: it stays only until the document is copied again in the new area. +#[cfg(unix)] +#[test] +fn a_copy_written_into_an_area_left_meanwhile_goes_once_copied_again() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-inflight-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(&dir)); + let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap(); + assert_eq!(jobs.len(), 1); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + for j in jobs { + j.write().unwrap(); + j.finish(&mut a); + } + assert!(a.doc().unwrap().recovery.is_none()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid], "the next heartbeat has it copied again"); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}"); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists()); + assert!(cmd::recovery::heartbeat(&mut a).is_empty()); + drop(other); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another app locked the area meanwhile and the document is unchanged since its copy: the next +/// recovery save still writes the copy, in the new area. +#[cfg(unix)] +#[test] +fn a_recovery_save_copies_unchanged_documents_into_the_new_area() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-unchanged-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file()); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists()); + drop(other); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy left in an area the session had to leave goes at once when its document is saved, +/// reverted or closed, and every one goes when the app quits with nothing unsaved. +#[cfg(unix)] +#[test] +fn copies_left_behind_go_when_their_document_is_saved_reverted_or_closed() { + use cmd::recovery::Lock; + let base = std::env::temp_dir().join(format!("vc-recovery-leftgo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("recovery"); + let path = base.join("doc.vectorcraft").to_string_lossy().to_string(); + let left = |area: &str| std::fs::read_dir(dir.join(area)).unwrap().flatten().any(|e| e.path().extension().is_some_and(|x| x == "vectorcraft")); + for how in ["save", "revert", "close", "quit"] { + let (mut a, _) = saved_in(&dir); + a.execute("document.save", &json!({"path": path})).unwrap(); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + assert!(left(&area), "{how}"); + match how { + "save" => a.execute("document.save", &json!({"path": path})).map(drop).unwrap(), + "revert" => a.execute("file.revert", &json!({})).map(drop).unwrap(), + "close" => a.execute("file.close", &json!({})).map(drop).unwrap(), + _ => cmd::recovery::forget_all(&mut a), + } + assert!(!left(&area), "{how}"); + drop(other); + drop(a); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// The lock file was removed: the next heartbeat locks the area again, before any recovery save. +#[cfg(unix)] +#[test] +fn a_heartbeat_locks_the_area_again_when_its_lock_file_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-beatlock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "the copy is still there"); + assert_eq!(a.recovery.own_area(), Some(area.as_str())); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The lock file was removed and can't be made again (the area folder can't be written to now): +/// the session takes a new area and writes its copy there. +#[cfg(unix)] +#[test] +fn an_area_whose_lock_file_cannot_be_made_again_is_left_for_a_new_one() { + use std::os::unix::fs::PermissionsExt as _; + + use cmd::recovery::Lock; + // Permissions don't stop the superuser. + if rustix::process::geteuid().is_root() { + return; + } + let dir = std::env::temp_dir().join(format!("vc-recovery-readonly-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(matches!(FolderStore::new(&dir).lock(&mine), Ok(Lock::Busy))); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap(); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy left in an area the session had to leave is still the session's own after the app that +/// locked that area is gone: the session neither offers nor discards it. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_is_not_offered_by_its_own_app() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftmine-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + drop(other); + assert!(offered(&mut a).is_empty()); + let r = a.execute("file.recovery.discard", &json!({})).unwrap(); + assert!(r["discarded"].as_array().unwrap().is_empty(), "{r}"); + assert!(a.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A complex document (Data Recovery off for those) gets no new copy after the session left its +/// area: it keeps the copy it left there, and the heartbeat asks for it only once. +#[cfg(unix)] +#[test] +fn a_complex_documents_copy_left_behind_is_asked_for_once() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftcomplex-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut a = other_app(&dir); + let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0); + let layer = d.layers[0].id; + for i in 0..cmd::recovery::COMPLEX_OBJECTS { + let id = d.alloc_id(); + let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0); + let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()); + d.insert(Some(layer), i, n).unwrap(); + } + a.add_document(d, None); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let copy = r["saved"][0]["file"].as_str().unwrap().to_string(); + let area = a.recovery.own_area().unwrap().to_string(); + a.prefs.recovery_off_for_complex = true; + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert!(r["saved"].as_array().unwrap().is_empty(), "{r}"); + assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "asked for once"); + assert!(dir.join(format!("{copy}.vectorcraft")).is_file(), "its copy stays"); + drop(other); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The preference named another folder while a copy was written in the background: the copy goes +/// from the folder it was written to, and the document keeps its copy in the new folder. +#[test] +fn a_copy_finished_after_the_folder_changed_goes_from_its_own_folder() { + let base = std::env::temp_dir().join(format!("vc-recovery-refolder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (old, new) = (base.join("old"), base.join("new")); + let (mut a, area) = saved_in(&old); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(&old)); + let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap(); + a.prefs.recovery_folder = new.to_string_lossy().to_string(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.doc().unwrap().recovery.as_ref().unwrap().file.clone(); + for j in jobs { + j.write().unwrap(); + j.finish(&mut a); + } + assert!(!old.join(&area).join("Untitled-1-1.vectorcraft").exists()); + assert_eq!(a.doc().unwrap().recovery.as_ref().unwrap().file, mine); + assert!(new.join(format!("{mine}.vectorcraft")).is_file()); + drop(a); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn saving_reverting_or_closing_removes_the_copy() { let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 443d4aad3f..f6188babca 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -105,8 +105,8 @@ fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, pick: impl Fn let mut started = 0; for job in jobs { let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); - let (done, store) = (job.clone(), store.clone()); - let work = move |_: Writer| job.write(store.as_ref()); + let done = job.clone(); + let work = move |_: Writer| job.write(); let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| { let r = r?; done.finish(&mut app.session); From 935ebccc258c7e584780987c8376548a0ed42423 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:38:55 -0800 Subject: [PATCH 036/126] EPS: Save As options are menus with readable names, and Export As's Range shows an example Save As to an EPS file showed Preview Format, Overprints and Flattener Preset as text fields holding their ids (tiffColor, preserve, medium), the PostScript level as a number field, and labeled every EPS option except Use Artboards from its name ("Cmyk Post Script", "Thumbnails"). The dialog now shows the options Export's EPS Options shows. Preview, Overprints, Flattener preset (the built-in presets, then saved ones) and PostScript are menus of readable names. Transparent and Opaque apply to the color TIFF preview only. Embed Fonts shows checked and disabled. The PostScript level comes last. A pick writes the same values as before (previewFormat "tiffColor", a built-in preset's id, a saved preset's name, the level as a number). A value that the save would reject shows as it is. Every option the dialog shows has a written label. "Preview:" has a row in every catalog, and the de, it, ja, ko, uk, vi and zh-hant catalogs gain the menu names, which Export's EPS Options also showed in English. Export As's Range field shows the "1-3, 5" example when emptied, as Save As's does. --- crates/ui-egui/src/dialogs/export_as.rs | 3 +- crates/ui-egui/src/dialogs/save_options.rs | 136 ++++++++++++++-- .../ui-egui/src/dialogs/save_options/tests.rs | 148 ++++++++++++++++++ crates/ui-egui/src/dialogs/tests_export.rs | 9 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 9 ++ crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 9 ++ crates/ui-egui/src/i18n/ja.tsv | 9 ++ crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 9 ++ crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tests.rs | 21 +++ crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 9 ++ crates/ui-egui/src/i18n/vi.tsv | 9 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 9 ++ docs/control-protocol.md | 6 +- 33 files changed, 394 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 246aaf23f1..c0ed169e9b 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -103,7 +103,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("all".into(), json!(all)); } let mut range = d.str("range"); - if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(90.0)).changed() { + let field = egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(90.0).hint_text("1-3, 5"); + if ui.add_enabled(!all, field).changed() { d.fields.insert("range".into(), json!(range)); } }); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 6381f1e863..5dfb886044 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -9,7 +9,10 @@ //! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, //! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. EPS shows Use Artboards //! (`useArtboards`, off by default: the bounding box fits the visible art) with the same `all` or -//! `range` under it. A native file's Version is a menu of the versions VectorCraft writes +//! `range` under it, then the options Export's EPS Options shows: Preview, Overprints, Flattener +//! preset and PostScript are menus that write the option's value (`previewFormat: "tiffColor"`, +//! `overprints: "preserve"`, `flattenerPreset: "medium"` or a saved preset's name, `level: 3`). +//! A native file's Version is a menu of the versions VectorCraft writes //! (`version`, named by the releases that read each). A //! `.ai` file's Version comes first: PDF-compatible, or legacy version 7 (`aiVersion: "7"`, a file //! per artboard), which takes only Transparent Images (`transparentImages`), Hidden Objects @@ -20,7 +23,9 @@ use std::sync::OnceLock; use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::FlattenOptions; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode, TEMPLATE_FORMATS}; +use vectorcraft_eps::{Level, Overprint, Preview}; use super::{DialogResult, DialogSpec, form}; use crate::state::Dialog; @@ -109,19 +114,87 @@ fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> { }) } -/// An option's label: the save options as the reference app's options dialog names them, the -/// others from their names. +/// An option's label: its written label, or for an option without one, its name split into words. fn label(o: &FormatOption) -> String { - match o.name { + written_label(o).unwrap_or_else(|| form::humanize(o.name)) +} + +/// The written label of option `o`, translated. +fn written_label(o: &FormatOption) -> Option<String> { + Some(match o.name { "separateArtboards" => tl!("Save each artboard to a separate file").into(), "useArtboards" => tl!("Use Artboards").into(), - "includeLinked" => tl!("Include Linked Files").into(), + "includeLinked" | "includeLinkedFiles" => tl!("Include Linked Files").into(), "embedProfiles" => tl!("Embed ICC Profiles").into(), "pdfCompatible" => tl!("Create PDF-Compatible File").into(), "compress" => tl!("Use Compression").into(), "editingData" => tl!("Include Editing Data for Other Apps").into(), - _ => form::humanize(o.name), + "version" | "aiVersion" => tl!("Version:").into(), + "transparentImages" => tl!("Transparent Images:").into(), + "hiddenObjects" => tl!("Hidden Objects:").into(), + "outlineText" => tl!("Text:").into(), + "preview" => tl!("Preview").into(), + "thumbnails" => tl!("Include Document Thumbnails").into(), + "previewFormat" => tl!("Preview:").into(), + "transparentPreview" => tl!("Transparent").into(), + "overprints" => tl!("Overprints:").into(), + "flattenerPreset" => tl!("Flattener preset:").into(), + "embedFonts" => tl!("Embed Fonts (for other applications)").into(), + "cmykPostScript" => tl!("Include CMYK PostScript in RGB Files").into(), + "compatibleGradients" => tl!("Compatible Gradient and Gradient Mesh Printing").into(), + "level" => tl!("PostScript:").into(), + _ => return None, + }) +} + +/// The values of an option that takes one of a fixed set, each with the name its menu shows, and +/// `at`, the entry the option's value picks as the save reads it (none when the save would reject +/// the value). The first `builtins` names are VectorCraft's own (translated where they are shown); +/// the others are names the user gave (saved flattener presets), shown as they are. +struct Choices { + values: Vec<Value>, + names: Vec<String>, + builtins: usize, + at: Option<usize>, +} + +/// The choices of `f`'s option `o`, whose value is `value`, when it takes one of a fixed set of +/// values: EPS's preview format, overprints, PostScript level and flattener preset (the built-in +/// presets by id, then the saved ones by name). +fn choices(app: &VectorcraftApp, f: &Format, o: &FormatOption, value: &Value) -> Option<Choices> { + if f.id != "eps" { + return None; } + let text = value.as_str().map_or_else(|| value.to_string(), str::to_string); + let (pairs, builtins, at): (Vec<(Value, String)>, Option<usize>, Option<usize>) = match o.name { + "previewFormat" => ( + Preview::ALL.iter().map(|p| (json!(p.id()), p.label().to_string())).collect(), + None, + Preview::from_id(&text).and_then(|p| Preview::ALL.iter().position(|q| *q == p)), + ), + "overprints" => ( + Overprint::ALL.iter().map(|p| (json!(p.id()), p.label().to_string())).collect(), + None, + Overprint::from_id(&text).and_then(|p| Overprint::ALL.iter().position(|q| *q == p)), + ), + // The level is a number in the save options. + "level" => ( + Level::ALL.iter().filter_map(|l| Some((json!(l.id().parse::<u64>().ok()?), l.label().to_string()))).collect(), + None, + Level::from_id(&text).and_then(|l| Level::ALL.iter().position(|q| *q == l)), + ), + "flattenerPreset" => { + let presets = app.session.flattener_presets(); + let id = |name: &str| FlattenOptions::PRESETS.into_iter().find(|id| FlattenOptions::preset_label(id) == Some(name)); + let pairs = presets.iter().map(|p| (id(&p.name).map_or_else(|| json!(p.name), |id| json!(id)), p.name.clone())).collect(); + let at = app.session.flattener_preset(&text).and_then(|p| presets.iter().position(|q| q.name == p.name)); + (pairs, Some(FlattenOptions::builtin_presets().len()), at) + } + _ => return None, + }; + let builtins = builtins.unwrap_or(pairs.len()); + let (values, names): (Vec<Value>, Vec<String>) = pairs.into_iter().unzip(); + Some(Choices { values, names, builtins, at }) } /// Every artboard (the All choice under Save each artboard to a separate file or Use Artboards): @@ -181,9 +254,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { // Version first; the legacy version takes only its own options. let legacy = legacy_ai(d); let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect(); - rows.sort_by_key(|o| o.name != "aiVersion"); + // EPS's PostScript level comes last, as in Export's EPS Options. + rows.sort_by_key(|o| (o.name != "aiVersion", f.id == "eps" && o.name == "level")); for o in rows { - option_row(app, ui, d, o); + option_row(app, ui, d, f, o); } }); if legacy_ai(d) && app.session.active().is_some_and(|st| st.doc.artboards.len() > 1) { @@ -344,8 +418,9 @@ fn artboard_picker(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } } -/// One option: a checkbox, an artboard menu, a version menu, a number or a text field. -fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { +/// One option of format `f`: a checkbox, an artboard menu, a version menu, a menu of its +/// [`choices`], a number or a text field. +fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, f: &Format, o: &FormatOption) { let t = Tokens::get(ui.ctx()); let label = label(o); let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); @@ -397,6 +472,40 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma ui.end_row(); return; } + if let Some(c) = choices(app, f, o, &value) { + ui.label(egui::RichText::new(&label).color(t.text_dim)).on_hover_text(o.description); + // A value none of them names shows as it is. + let current = c.at.and_then(|i| c.names.get(i)).cloned().unwrap_or_else(|| value.as_str().map_or_else(|| value.to_string(), str::to_string)); + let names: Vec<&str> = c.names.iter().map(String::as_str).collect(); + if let Some(i) = super::mixed_dropdown(ui, ("save-option", o.name), ¤t, &names, 254.0, |k| k < c.builtins) { + choose(d, o, &c, i); + } + ui.end_row(); + return; + } + if f.id == "eps" && o.name == "transparentPreview" { + // Only the color preview can keep transparency. + ui.label(""); + let color = Preview::from_id(&d.str("previewFormat")) == Some(Preview::TiffColor); + let transparent = value.as_bool().unwrap_or(true); + ui.horizontal(|ui| { + for (name, on) in [(tl!("Transparent"), true), (tl!("Opaque"), false)] { + if widgets::radio(ui, name, color && transparent == on, color) { + d.fields.insert(o.name.into(), json!(on)); + } + ui.add_space(12.0); + } + }); + ui.end_row(); + return; + } + if f.id == "eps" && o.name == "embedFonts" { + // Type is written as glyph outlines, which need no fonts. + ui.label(""); + widgets::check_tip(ui, &label, true, false, o.description); + ui.end_row(); + return; + } if o.ty == "boolean" { ui.label(""); let on = value.as_bool().unwrap_or(false); @@ -435,6 +544,13 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma ui.end_row(); } +/// Option `o` set to the value of entry `i` of its menu `c`. +fn choose(d: &mut Dialog, o: &FormatOption, c: &Choices, i: usize) { + if let Some(v) = c.values.get(i) { + d.fields.insert(o.name.into(), v.clone()); + } +} + /// The native versions VectorCraft writes, newest first, each with the first release that reads /// it (the format version each release tag writes: 2 in 0.1, 3 from 0.2, 4 from 0.8). const NATIVE_VERSIONS: [(u32, &str); 4] = [(4, "0.8"), (3, "0.2"), (2, "0.1"), (1, "0.1")]; diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 68a88eaf11..968d79568c 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -407,6 +407,154 @@ fn eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard() { assert_eq!(bounding_box(&saved[2].1), "%%BoundingBox: 0 0 100 80"); } +/// Save As › EPS shows the options of Export's EPS Options: Preview, Overprints, Flattener preset +/// and PostScript as menus of readable names (not fields holding `tiffColor`, `preserve` and +/// `medium`), the checkboxes under written labels, and the PostScript level last. +#[test] +fn eps_save_options_are_menus_with_readable_names() { + let (mut app, _, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + let lines: Vec<&str> = text.lines().collect(); + for shown in [ + "Preview:", + "TIFF (8-bit Color)", + "Transparent", + "Opaque", + "Overprints:", + "Preserve", + "Flattener preset:", + "Medium Resolution", + "Embed Fonts (for other applications)", + "Include Linked Files", + "Include Document Thumbnails", + "Include CMYK PostScript in RGB Files", + "Compatible Gradient and Gradient Mesh Printing", + "PostScript:", + "Language Level 3", + ] { + assert!(lines.contains(&shown), "{shown:?} in {text}"); + } + for id in ["tiffColor", "preserve", "medium", "Cmyk Post Script", "Preview Format:", "Level:", "Thumbnails"] { + assert!(!lines.contains(&id), "{id:?} in {text}"); + } + let at = |s: &str| lines.iter().position(|l| *l == s).unwrap(); + assert!(at("PostScript:") > at("Compatible Gradient and Gradient Mesh Printing"), "{text}"); +} + +/// Every option the save options dialog shows, in every format it serves, has a written label: +/// none is painted as its name split into words. +#[test] +fn save_options_have_written_labels() { + // The web's Save As and Save as Template dialogs show the options of every format they pick. + let formats = SAVE_FORMATS.iter().map(|id| (*id, "file.saveAs")).chain(TEMPLATE_FORMATS.iter().map(|id| (*id, "file.saveAsTemplate"))); + for (id, command) in formats { + let (mut app, _) = web(); + app.run(command, json!({})).unwrap(); + let mut d = app.ui.dialog.take().unwrap(); + switch_format(&mut app, &mut d, id); + app.ui.dialog = Some(d); + assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("format").as_str()), (KIND, id)); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + let f = fileio::format(id).unwrap(); + for o in shown(f) { + let written = written_label(o); + assert!(written.is_some(), "{id}: {} has no written label", o.name); + let split = form::humanize(o.name); + let split = split.trim_end_matches(':'); + assert!( + written.as_deref().map(|w| w.trim_end_matches(':')) == Some(split) || !text.lines().any(|l| l == split), + "{id}: {split:?} in {text}" + ); + } + } +} + +/// A pick from an EPS menu saves the option's value: the id, the level as a number, a saved +/// flattener preset by its name. The file is written with them. +#[test] +fn picking_eps_menu_items_saves_their_values() { + let (mut app, written, _) = desktop("art.eps"); + app.session.prefs.flattener_presets.push(vectorcraft_engine::cmd::FlattenerPreset { + name: "Mine".into(), + options: vectorcraft_engine::cmd::FlattenOptions::preset("low").unwrap(), + }); + app.run("file.saveAs", json!({})).unwrap(); + let f = fileio::format("eps").unwrap(); + let mut d = app.ui.dialog.take().unwrap(); + for (name, pick, value) in [ + ("previewFormat", "None", json!("none")), + ("overprints", "Discard", json!("discard")), + ("flattenerPreset", "Mine", json!("Mine")), + ("level", "Language Level 2", json!(2)), + ] { + let o = f.options.iter().find(|o| o.name == name).unwrap(); + let c = choices(&app, f, o, &Value::Null).unwrap_or_else(|| panic!("{name} is a menu")); + let i = c.names.iter().position(|n| n == pick).unwrap_or_else(|| panic!("{pick} in {name}: {:?}", c.names)); + choose(&mut d, o, &c, i); + assert_eq!(d.fields[name], value, "{name}"); + assert_eq!(choices(&app, f, o, &value).unwrap().at, Some(i), "{name}: the menu shows the pick"); + } + // The built-in presets come first, by id. A value picks the entry the save reads it as: a + // preset by id or name in any case, a level by its digits, a preview or overprint by id only. + let at = |name: &str, value: Value| { + let o = f.options.iter().find(|o| o.name == name).unwrap(); + choices(&app, f, o, &value).unwrap().at + }; + let o = f.options.iter().find(|o| o.name == "flattenerPreset").unwrap(); + let c = choices(&app, f, o, &Value::Null).unwrap(); + assert_eq!(c.values, [json!("high"), json!("medium"), json!("low"), json!("Mine")]); + assert_eq!((c.builtins, c.at), (3, None)); + assert_eq!( + (at("flattenerPreset", json!("MEDIUM RESOLUTION")), at("flattenerPreset", json!("mine")), at("flattenerPreset", json!("Other"))), + (Some(1), Some(3), None) + ); + assert_eq!((at("level", json!("level2")), at("level", json!(3)), at("level", json!(4))), (Some(0), Some(1), None)); + assert_eq!((at("previewFormat", json!("TIFFBW")), at("previewFormat", json!("TIFF (8-bit Color)"))), (Some(1), None)); + assert_eq!((at("overprints", json!("Discard")), at("overprints", json!("keep"))), (Some(1), None)); + app.ui.dialog = Some(d); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "saved: {}", app.ui.status); + let saved = &app.session.active().unwrap().save_options; + for (name, value) in [("previewFormat", json!("none")), ("overprints", json!("discard")), ("flattenerPreset", json!("Mine")), ("level", json!(2))] + { + assert_eq!(saved[name], value, "{name}"); + } + let bytes = written.borrow()[0].1.clone(); + let (ps, preview) = vectorcraft_eps::sections(&bytes).unwrap(); + assert!(preview.is_none(), "no preview"); + assert!(String::from_utf8_lossy(ps).contains("%%LanguageLevel: 2")); +} + +/// Save As › EPS: Transparent and Opaque apply to the color TIFF preview only, and are disabled +/// with any other preview. Embed Fonts shows checked and disabled. A flattener preset that no +/// entry of the menu names shows as it is. +#[test] +fn eps_save_transparency_follows_the_color_preview() { + use crate::widgets::accessibility_tests::{named, role_and_state, tree}; + use egui::accesskit::Toggled; + let (mut app, _, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let set = |app: &mut VectorcraftApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + set(&mut app, "previewFormat", json!("tiffColor")); + set(&mut app, "transparentPreview", json!(false)); + let t = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert!(!named(&t, "Transparent").is_disabled() && !named(&t, "Opaque").is_disabled()); + assert_eq!((role_and_state(&t, "Transparent").1, role_and_state(&t, "Opaque").1), (Some(Toggled::False), Some(Toggled::True))); + let fonts = named(&t, "Embed Fonts (for other applications)"); + assert_eq!((fonts.is_disabled(), fonts.toggled()), (true, Some(Toggled::True))); + for preview in ["none", "tiffBw"] { + set(&mut app, "previewFormat", json!(preview)); + let t = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert!(named(&t, "Transparent").is_disabled() && named(&t, "Opaque").is_disabled(), "{preview}"); + } + set(&mut app, "flattenerPreset", json!("Gone")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + assert!(text.lines().any(|l| l == "Gone"), "{text}"); +} + /// #4931: a native file's Version is a menu of the versions VectorCraft writes, the current one /// first and chosen, each named by the releases that read it. An older version without /// compression saves plain, whatever Use Compression held. diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 891fb586be..93bb5dec75 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -180,6 +180,15 @@ fn a_bad_range_keeps_export_as_open() { assert!(written.borrow().is_empty()); } +/// An emptied Range field shows an example of the artboard numbers it takes, as Save As's does. +#[test] +fn an_empty_export_as_range_shows_an_example() { + let (mut app, _) = app(2); + app.run("file.exportAs", json!({"useArtboards": true, "range": ""})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.lines().any(|l| l == "1-3, 5"), "{text}"); +} + #[test] fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() { let (mut app, written) = app(2); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2d8bffbf95..53e27cc3a5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2461,6 +2461,7 @@ font-technology Variable Променливи Presets: Предварителни настройки: Press Натиснете Preview Преглед + Preview: Преглед: Preview All Layers Преглед на всички слоеве Preview in Browser Преглед в браузър Preview in Browser needs the desktop app (the web downloads files instead) Прегледът в браузър изисква настолното приложение (уеб версията изтегля файловете вместо това) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e5b3c12768..0a52c55f86 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -544,6 +544,7 @@ Gaussian Blur… Gaussovo zamućenje… View Prikaz Preview Pregled + Preview: Pregled: Overprint Preview Pregled pretiska Pixel Preview Pregled piksela Trim View Prikaz obrezivanja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 298f10b5f3..be89adc5f3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3194,6 +3194,7 @@ digits Malayalam Malaiàlam Press Quality Qualitat d’impressió Pressure Pressió Preview Previsualització + Preview: Previsualització: Preview (1 inch on the page) Previsualització (1 polzada a la pàgina) Preview All Layers Previsualitza totes les capes Preview in Browser Previsualitza al navegador diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7517fc8ae6..8857c933f5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -552,6 +552,7 @@ Gaussian Blur… Gaussovské rozostření… View Zobrazení Preview Náhled + Preview: Náhled: Overprint Preview Náhled přetisků Pixel Preview Náhled obrazových bodů Trim View Oříznuté zobrazení diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0d42626aae..e92cbc29d7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -508,6 +508,7 @@ Gaussian Blur… Gaussisk sløring… View Vis Preview Forhåndsvisning + Preview: Forhåndsvisning: Overprint Preview Overprint-forhåndsvisning Pixel Preview Pixel-forhåndsvisning Trim View Beskåret visning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 27fc28e49d..bf1b0a06a0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -798,6 +798,14 @@ font-technology Variable Variabel Disable Auto Add/Delete Automatisches Hinzufügen/Löschen deaktivieren Disable Opacity Mask Deckkraftmaske deaktivieren Discard Verwerfen + Preserve Beibehalten + TIFF (Black & White) TIFF (Schwarzweiß) + TIFF (8-bit Color) TIFF (8-Bit-Farbe) + High Resolution Hohe Auflösung + Medium Resolution Mittlere Auflösung + Low Resolution Niedrige Auflösung + Language Level 2 Sprachebene 2 + Language Level 3 Sprachebene 3 Discard Empty Type Leeren Text verwerfen Discard Recovered Documents Wiederhergestellte Dokumente verwerfen Discard White Overprint in Output Weißes Überdrucken in der Ausgabe verwerfen @@ -2490,6 +2498,7 @@ diagnostics ui Oberfläche Presets: Vorgaben: Press Mit Preview Vorschau + Preview: Vorschau: Preview All Layers Vorschau aller Ebenen Preview in Browser Vorschau im Browser Preview in Browser needs the desktop app (the web downloads files instead) „Vorschau im Browser“ erfordert die Desktop-App (im Web werden stattdessen Dateien heruntergeladen) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c5b6c80236..ff22fce80b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1879,6 +1879,7 @@ font-technology Variable Μεταβλητές Preset: Προεπιλογή: Presets: Προεπιλογές: Preview Προεπισκόπηση + Preview: Προεπισκόπηση: Previous Προηγούμενο Print Εκτύπωση Printer Εκτυπωτής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8ae6850038..454bd9328f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2534,6 +2534,7 @@ font-technology Variable Variables Presets: Preajustes: Press Presiona Preview Vista previa + Preview: Vista previa: Preview All Layers Previsualizar todas las capas Preview in Browser Previsualizar en el navegador Preview in Browser needs the desktop app (the web downloads files instead) Previsualizar en el navegador requiere la aplicación de escritorio (la versión web descarga los archivos) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cb3c99ceda..22477cb3ad 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -546,6 +546,7 @@ Gaussian Blur… Gaussin sumennus… View Näytä Preview Esikatselu + Preview: Esikatselu: Overprint Preview Päällepainatuksen esikatselu Pixel Preview Pikseliesikatselu Trim View Rajattu näkymä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 18e3e1d499..123152dbf7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2526,6 +2526,7 @@ font-technology Variable Variables Presets: Paramètres prédéfinis : Press Appuyez sur Preview Aperçu + Preview: Aperçu : Preview All Layers Aperçu de tous les calques Preview in Browser Aperçu dans le navigateur Preview in Browser needs the desktop app (the web downloads files instead) L’aperçu dans le navigateur nécessite l’application de bureau (la version web télécharge les fichiers à la place) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index f07175090f..da99be1506 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3487,6 +3487,7 @@ antiAlias Sharp शार्प Press Quality प्रेस गुणवत्ता Pressure दबाव Preview प्रीव्यू + Preview: प्रीव्यू: Preview (1 inch on the page) प्रीव्यू (1 इंच पृष्ठ पर) Preview All Layers सभी लेयर्स का प्रीव्यू करें Preview in Browser ब्राउज़र में प्रीव्यू diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7b7d200d86..169e4c222b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3082,6 +3082,7 @@ diagnostics ui felület Press keys… Nyomd le a billentyűket… Pressure Nyomás Preview Előnézet + Preview: Előnézet: Preview All Layers Összes réteg előnézetben Preview in Browser Előnézet böngészőben Preview in Browser needs the desktop app (the web downloads files instead) Az Előnézet böngészőben az asztali alkalmazásban érhető el (a webes változat helyette letölti a fájlokat) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d5d37dac4f..c70b8f99bc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -508,6 +508,7 @@ Gaussian Blur… Kabur Gaussian… View Tampilan Preview Pratinjau + Preview: Pratinjau: Overprint Preview Pratinjau overprint Pixel Preview Pratinjau piksel Trim View Tampilan terpangkas diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f12d5dfba..0d478f9e60 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -819,6 +819,14 @@ font-technology Variable Variabili Disable Auto Add/Delete Disattiva aggiunta/eliminazione automatica Disable Opacity Mask Disattiva maschera di opacità Discard Scarta + Preserve Mantieni + TIFF (Black & White) TIFF (bianco e nero) + TIFF (8-bit Color) TIFF (colore a 8 bit) + High Resolution Alta risoluzione + Medium Resolution Media risoluzione + Low Resolution Bassa risoluzione + Language Level 2 Livello di linguaggio 2 + Language Level 3 Livello di linguaggio 3 Discard Empty Type Scarta testo vuoto Discard Recovered Documents Scarta documenti recuperati Discard White Overprint in Output Scarta sovrastampa del bianco nell'output @@ -2566,6 +2574,7 @@ diagnostics ui interfaccia Presets: Preset: Press Premi Preview Anteprima + Preview: Anteprima: Preview All Layers Anteprima di tutti i livelli Preview in Browser Anteprima nel browser Preview in Browser needs the desktop app (the web downloads files instead) Anteprima nel browser richiede l'app desktop (la versione web scarica invece i file) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e26af98500..525352f478 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -848,6 +848,7 @@ digits Malayalam マラヤーラム数字 Search Commands… コマンドを検索… VectorCraft Help… VectorCraft ヘルプ… Preview プレビュー + Preview: プレビュー: Show Edges エッジを表示 Show Corner Widget コーナーウィジェットを表示 Show Template テンプレートを表示 @@ -1437,6 +1438,14 @@ antiAlias Sharp シャープ Disable Auto Add/Delete 自動追加/削除機能を使用しない Disable Opacity Mask 不透明マスクを無効にする Discard 破棄 + Preserve 保持 + TIFF (Black & White) TIFF (白黒) + TIFF (8-bit Color) TIFF (8 bit カラー) + High Resolution 高解像度 + Medium Resolution 中解像度 + Low Resolution 低解像度 + Language Level 2 言語レベル 2 + Language Level 3 言語レベル 3 Discard Empty Type 空のテキストを破棄 Discard Recovered Documents 復帰したドキュメントを破棄 Discard White Overprint in Output 出力時に白のオーバープリントを破棄 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9eb24e51d1..d094007fb6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -482,6 +482,7 @@ Gaussian Blur… გაუსის გაბუნდოვნება… View ხედი Preview წინასწარი ნახვა + Preview: წინასწარი ნახვა: Overprint Preview ზედდაბეჭდვის წინასწარი ნახვა Pixel Preview პიქსელების წინასწარი ნახვა Trim View მოჭრილი ხედი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 59e5ab62aa..9e28f0595d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -841,6 +841,7 @@ digits Malayalam 말라얄람어 Search Commands… 명령 검색… VectorCraft Help… VectorCraft 도움말… Preview 미리 보기 + Preview: 미리 보기: Show Edges 가장자리 표시 Show Corner Widget 모퉁이 위젯 표시 Show Template 템플릿 표시 @@ -1427,6 +1428,14 @@ antiAlias Sharp 날카롭게 Disable Auto Add/Delete 자동 추가/삭제 사용 안 함 Disable Opacity Mask 불투명 마스크 사용 안 함 Discard 삭제 + Preserve 보존 + TIFF (Black & White) TIFF (흑백) + TIFF (8-bit Color) TIFF (8비트 색상) + High Resolution 고해상도 + Medium Resolution 중간 해상도 + Low Resolution 저해상도 + Language Level 2 언어 수준 2 + Language Level 3 언어 수준 3 Discard Empty Type 빈 문자 삭제 Discard Recovered Documents 복구된 문서 삭제 Discard White Overprint in Output 출력에서 흰색 오버프린트 삭제 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3bed722a2a..e2937d7939 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -522,6 +522,7 @@ Gaussian Blur… Gaussiaanse vervaging… View Beeld Preview Voorbeeld + Preview: Voorbeeld: Overprint Preview Overprintvoorbeeld Pixel Preview Pixelvoorbeeld Trim View Bijsnijweergave diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index baea8e14af..f99072bb11 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -511,6 +511,7 @@ Gaussian Blur… Rozmycie gaussowskie… View Widok Preview Podgląd + Preview: Podgląd: Overprint Preview Podgląd nadruku Pixel Preview Podgląd pikseli Trim View Widok przycięty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0155affc00..58616a44cb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2107,6 +2107,7 @@ antiAlias Sharp Nítido Presets: Predefinidos: Press Pressione Preview Visualização + Preview: Visualização: Preview Mode Modo de Visualização Preview in Browser Visualizar no Navegador Preview the document as it is now Visualizar o documento como está agora diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f346b99a4a..fe81696d62 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2561,6 +2561,7 @@ diagnostics ui интерфейс Presets: Наборы: Press Нажмите Preview Просмотр + Preview: Просмотр: Preview All Layers Просмотреть все слои Preview in Browser Просмотреть в браузере Preview in Browser needs the desktop app (the web downloads files instead) Просмотр в браузере требует настольного приложения (web-версия скачивает файлы) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index bff5500ade..3af6395188 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2471,6 +2471,7 @@ font-technology Variable Variabla Presets: Förinställningar: Press Tryck på Preview Förhandsgranskning + Preview: Förhandsgranskning: Preview All Layers Förhandsgranska alla lager Preview in Browser Förhandsgranska i webbläsare Preview in Browser needs the desktop app (the web downloads files instead) Förhandsgranska i webbläsare kräver skrivbordsappen (webbversionen hämtar filerna i stället) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..1874e1f283 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -238,6 +238,27 @@ fn anti_aliasing_method_labels_are_in_complete_catalogs() { } } +/// The names the EPS options' menus show (preview, overprints, flattener preset and PostScript +/// level), and Save As's Preview label. +#[test] +fn eps_option_menu_names_are_in_complete_catalogs() { + use vectorcraft_engine::cmd::FlattenOptions; + use vectorcraft_eps::{Level, Overprint, Preview}; + let names: Vec<&str> = Preview::ALL + .map(Preview::label) + .into_iter() + .chain(Overprint::ALL.map(Overprint::label)) + .chain(Level::ALL.map(Level::label)) + .chain(FlattenOptions::PRESETS.into_iter().filter_map(FlattenOptions::preset_label)) + .chain(["Preview:"]) + .collect(); + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in &names { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1ee0a6410b..7e9ce4fc42 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2344,6 +2344,7 @@ shape Open Açık Presets: Ön ayarlar: Press Basın Preview Önizleme + Preview: Önizleme: Preview All Layers Tüm Katmanları Önizle Preview in Browser Tarayıcıda Önizleme Preview in Browser needs the desktop app (the web downloads files instead) Tarayıcıdaki önizleme için masaüstü uygulaması gerekir (bunun yerine web, dosyaları indirir) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index deff98d6d4..31629f737e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -813,6 +813,14 @@ font-technology Variable Варіативні Disable Auto Add/Delete Вимкнути авто-додавання/видалення Disable Opacity Mask Вимкнути маску непрозорості Discard Відкинути + Preserve Зберігати + TIFF (Black & White) TIFF (чорно-білий) + TIFF (8-bit Color) TIFF (8-бітний колір) + High Resolution Висока роздільна здатність + Medium Resolution Середня роздільна здатність + Low Resolution Низька роздільна здатність + Language Level 2 Рівень мови 2 + Language Level 3 Рівень мови 3 Discard Empty Type Видалити порожній текст Discard Recovered Documents Відкинути відновлені документи Discard White Overprint in Output Скасувати накладення білого під час виведення @@ -2558,6 +2566,7 @@ diagnostics ui інтерфейс Presets: Набори: Press Натисніть Preview Перегляд + Preview: Перегляд: Preview All Layers Переглянути всі шари Preview in Browser Переглянути у браузері Preview in Browser needs the desktop app (the web downloads files instead) Перегляд у браузері вимагає настільної програми (web-версія завантажує файли) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 575507292f..aaf741467f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -954,6 +954,14 @@ font-technology Variable Biến thiên Disable Auto Add/Delete Tắt tự động thêm/xóa Disable Opacity Mask Tắt mặt nạ độ mờ Discard Loại bỏ + Preserve Bảo lưu + TIFF (Black & White) TIFF (Đen trắng) + TIFF (8-bit Color) TIFF (Màu 8 bit) + High Resolution Độ phân giải cao + Medium Resolution Độ phân giải vừa + Low Resolution Độ phân giải thấp + Language Level 2 Cấp ngôn ngữ 2 + Language Level 3 Cấp ngôn ngữ 3 Discard Empty Type Loại bỏ chữ trống Discard Recovered Documents Loại bỏ tài liệu đã phục hồi Discard White Overprint in Output Loại bỏ in chồng màu trắng khi xuất @@ -2867,6 +2875,7 @@ diagnostics ui giao diện Press Nhấn Pressure Áp lực Preview Xem trước + Preview: Xem trước: Preview All Layers Xem trước tất cả lớp Preview in Browser Xem trước trong trình duyệt Preview in Browser needs the desktop app (the web downloads files instead) Xem trước trong trình duyệt cần ứng dụng máy tính để bàn (web sẽ tải tệp xuống thay thế) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ddc308ee74..ed3481d89d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2432,6 +2432,7 @@ diagnostics ui 界面 Presets: 预设: Press 按 Preview 预览 + Preview: 预览: Preview in Browser 在浏览器中预览 Preview in Browser needs the desktop app (the web downloads files instead) 在浏览器中预览需要桌面版应用程序 (网页版会改为下载文件) Preview Mode 预览模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a2726f2d20..373b5c1354 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -781,6 +781,14 @@ font-technology Variable 可變 Disable Auto Add/Delete 停用自動增加/刪除 Disable Opacity Mask 停用不透明度遮色片 Discard 捨棄 + Preserve 保留 + TIFF (Black & White) TIFF (黑白) + TIFF (8-bit Color) TIFF (8 位元色彩) + High Resolution 高解析度 + Medium Resolution 中解析度 + Low Resolution 低解析度 + Language Level 2 語言層級 2 + Language Level 3 語言層級 3 Discard Empty Type 捨棄空白文字 Discard Recovered Documents 捨棄已復原的文件 Discard White Overprint in Output 輸出時捨棄白色疊印 @@ -3515,6 +3523,7 @@ diagnostics ui 介面 Presets: 預設集: Press 按下 Preview 預覽 + Preview: 預覽: Preview All Layers 預覽所有圖層 Preview in Browser 在瀏覽器中預覽 Preview in Browser needs the desktop app (the web downloads files instead) 在瀏覽器中預覽需要桌面版應用程式 (網頁版會改為下載檔案) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e302ce6336..2c4e4a5ff3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -787,7 +787,11 @@ artboards' files. A `.ai` file's `aiVersion` comes first: `"pdf"` (the default) Options"), which takes only `transparentImages` (`"flatten"` or `"omit"`), `hiddenObjects` (`"layer"`, `"omit"` or `"visible"`, the Hidden Objects menu), `outlineText` (the Text menu: false Editable, true Outlines) and, with several artboards, the artboard picker: `boards`, one bool per artboard (default: those last saved, else all), saved as `range`, one file per checked -artboard. OK is disabled with none checked. +artboard. OK is disabled with none checked. Save As to an EPS file opens the same dialog after the save panel (heading +"EPS Options") with `useArtboards`, `all` and `range`, then the options Export's EPS Options shows. Preview +(`previewFormat`), Overprints (`overprints`), Flattener preset (`flattenerPreset`) and PostScript (`level`) are menus: +a pick writes the option's value (`"tiffColor"`, `"preserve"`, a built-in preset's id such as `"medium"` or a saved +preset's name, the level as a number), and a value none of them names shows as it is. EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and From 4f08f19ce4bdfd7d6d9236c53e2d39107a626a42 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:10:02 -0800 Subject: [PATCH 037/126] Learn: a Learn panel lists tutorials by skill, opened from Explore tutorials on the app bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Learn and a new Explore tutorials button beside the search icon on the app bar show and hide the Learn panel. Its For you tab holds a Get started section with a Quick skill guide card that opens the Skills tab. The Skills tab lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up one level. The tutorials live in a data catalog whose test checks unique ids, that every named tool and command exists and that no skill is empty. - window.learn {show?} shows, hides or toggles the panel; showing it also shows a hidden dock, and the panel counts as hidden while the dock is hidden or a full screen mode hides the panels. - learn.list lists the skills and tutorials; learn.show {tutorial} opens one. A missing, non-string or unknown id is an error and changes nothing. - New labels and messages have rows in every catalog. The tutorial text is English. Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md. --- ASSETS.md | 1 + assets/icons/graduation-cap.svg | 17 ++ crates/mcp/src/ui_catalog.rs | 18 ++ crates/ui-egui/src/chrome.rs | 17 +- crates/ui-egui/src/i18n/bg.tsv | 13 ++ crates/ui-egui/src/i18n/bs.tsv | 13 ++ crates/ui-egui/src/i18n/ca.tsv | 13 ++ crates/ui-egui/src/i18n/cs.tsv | 13 ++ crates/ui-egui/src/i18n/da.tsv | 13 ++ crates/ui-egui/src/i18n/de.tsv | 13 ++ crates/ui-egui/src/i18n/el.tsv | 13 ++ crates/ui-egui/src/i18n/es.tsv | 13 ++ crates/ui-egui/src/i18n/fi.tsv | 13 ++ crates/ui-egui/src/i18n/fr.tsv | 13 ++ crates/ui-egui/src/i18n/hi.tsv | 13 ++ crates/ui-egui/src/i18n/hu.tsv | 13 ++ crates/ui-egui/src/i18n/id.tsv | 13 ++ crates/ui-egui/src/i18n/it.tsv | 13 ++ crates/ui-egui/src/i18n/ja.tsv | 13 ++ crates/ui-egui/src/i18n/ka.tsv | 13 ++ crates/ui-egui/src/i18n/ko.tsv | 13 ++ crates/ui-egui/src/i18n/nl.tsv | 13 ++ crates/ui-egui/src/i18n/pl.tsv | 13 ++ crates/ui-egui/src/i18n/pt-br.tsv | 13 ++ crates/ui-egui/src/i18n/ru.tsv | 13 ++ crates/ui-egui/src/i18n/sv.tsv | 13 ++ crates/ui-egui/src/i18n/tr.tsv | 13 ++ crates/ui-egui/src/i18n/uk.tsv | 13 ++ crates/ui-egui/src/i18n/vi.tsv | 13 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 13 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 13 ++ crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/state.rs | 1 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 18 ++ crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/panels/learn.rs | 214 +++++++++++++++++++ crates/ui-egui/src/panels/learn/catalog.rs | 185 +++++++++++++++++ crates/ui-egui/src/panels/learn/tests.rs | 231 +++++++++++++++++++++ crates/ui-egui/src/state.rs | 5 + docs/mcp.md | 20 ++ docs/target-app-parity.md | 1 + docs/ui-parity.md | 1 + 44 files changed, 1082 insertions(+), 3 deletions(-) create mode 100644 assets/icons/graduation-cap.svg create mode 100644 crates/ui-egui/src/panels/learn.rs create mode 100644 crates/ui-egui/src/panels/learn/catalog.rs create mode 100644 crates/ui-egui/src/panels/learn/tests.rs diff --git a/ASSETS.md b/ASSETS.md index 17ab680ff4..adddbc6957 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -265,6 +265,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/flip-vertical-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/folder-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/frame.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | +| `assets/icons/graduation-cap.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Learn panel, Explore tutorials button | | `assets/icons/grid-3x3.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/group.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/hand.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | diff --git a/assets/icons/graduation-cap.svg b/assets/icons/graduation-cap.svg new file mode 100644 index 0000000000..fbce25edcc --- /dev/null +++ b/assets/icons/graduation-cap.svg @@ -0,0 +1,17 @@ +<!-- @license lucide-static v1.49.0 - ISC --> +<svg + class="lucide lucide-graduation-cap" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" +> + <path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" /> + <path d="M22 10v6" /> + <path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" /> +</svg> diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e15244580d..f8a728a211 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -628,6 +628,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", ), + ( + "window.learn", + "Learn", + "", + "{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows", + ), + ( + "learn.list", + "List Tutorials", + "", + "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + ), + ( + "learn.show", + "Show Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f49851ad2c..0cd041723d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -72,8 +72,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // With the macOS menu bar the menus are at the top of the screen instead. let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) }; // The right-side group fills the space after the menus from the right; when it runs - // short, Discord goes first (it is also under Help), then the search icon (the palette - // stays under its shortcut), then the workspace switcher narrows. + // short, Discord goes first (it is also under Help), then Explore tutorials (also + // Window › Learn), then the search icon (the palette stays under its shortcut), then + // the workspace switcher narrows. let full = ui.max_rect(); let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() }; let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom())); @@ -97,7 +98,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let icon = 28.0; let search = switcher && room >= 64.0 + 8.0 + gap + icon; let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0); - let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false); + // Explore tutorials, beside the search icon, opens the Learn panel. + let learn = search && room >= ws_w + 8.0 + 2.0 * (gap + icon); + let discord = learn && room >= ws_w + 8.0 + 2.0 * (gap + icon) + 10.0 + gap + crate::community::discord_width(ui, false); let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. @@ -127,6 +130,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.palette_query.clear(); } } + if learn { + let shown = crate::panels::learn::visible(&app.ui); + if widgets::icon_button(ui, "graduation-cap", tl!("Explore tutorials"), shown, icon).clicked() + && let Err(e) = app.run("window.learn", json!({})) + { + app.ui.status = e; + } + } if discord { ui.add_space(10.0); crate::community::discord_button(app, ui, false); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..4403477ba9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6545,6 +6545,19 @@ breakText Glyphs Глифи @plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати @plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени +# Learn panel + Learn Обучение + Explore tutorials Разгледайте уроците + For you За вас + Skills Умения + Get started Първи стъпки + Quick skill guide Бързо ръководство за умения + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Кратки практически уроци за основните инструменти, групирани по умения. Изберете умение, за да видите уроците му. + List Tutorials Извеждане на уроци + Show Tutorial Показване на урок +@msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) +@msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) + # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти Deferred Effects Отложени ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..b9214734d1 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6596,6 +6596,19 @@ shape Open Otvoren @plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno @plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno +# Learn panel + Learn Učenje + Explore tutorials Istražite tutorijale + For you Za vas + Skills Vještine + Get started Prvi koraci + Quick skill guide Brzi vodič kroz vještine + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kratki praktični tutorijali za osnovne alate, grupisani po vještinama. Odaberite vještinu da vidite njene tutorijale. + List Tutorials Izlistaj tutorijale + Show Tutorial Prikaži tutorijal +@msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) +@msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) + # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte Deferred Effects Odgođeni efekti diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..811620833e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6802,6 +6802,19 @@ breakText Glyphs Glifs @plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last` +# Learn panel + Learn Aprèn + Explore tutorials Explora els tutorials + For you Per a vós + Skills Habilitats + Get started Primers passos + Quick skill guide Guia ràpida d’habilitats + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorials pràctics breus de les eines essencials, agrupats per habilitat. Trieu una habilitat per veure’n els tutorials. + List Tutorials Llista els tutorials + Show Tutorial Mostra el tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats Deferred Effects Efectes ajornats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..8b20d7de4f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6821,6 +6821,19 @@ breakText Glyphs Glyfy @plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno @msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` +# Learn panel + Learn Výuka + Explore tutorials Prozkoumat výukové kurzy + For you Pro vás + Skills Dovednosti + Get started Začínáme + Quick skill guide Rychlý průvodce dovednostmi + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krátké praktické výukové kurzy k základním nástrojům, seřazené podle dovedností. Vyberte dovednost a zobrazí se její kurzy. + List Tutorials Vypsat výukové kurzy + Show Tutorial Zobrazit výukový kurz +@msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) +@msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) + # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty Deferred Effects Odložené efekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..0055bcbcbb 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6559,6 +6559,19 @@ breakText Glyphs Glyffer @plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet @plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet +# Learn panel + Learn Lær + Explore tutorials Udforsk vejledninger + For you Til dig + Skills Færdigheder + Get started Kom i gang + Quick skill guide Hurtig færdighedsguide + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktiske vejledninger til de vigtigste værktøjer, ordnet efter færdighed. Vælg en færdighed for at se dens vejledninger. + List Tutorials Vis vejledninger + Show Tutorial Vis vejledning +@msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) +@msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) + # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter Deferred Effects Udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..23af70b207 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6408,6 +6408,19 @@ breakText Glyphs Glyphen @plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt @msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last` +# Learn panel + Learn Lernen + Explore tutorials Lernprogramme entdecken + For you Für Sie + Skills Fertigkeiten + Get started Erste Schritte + Quick skill guide Kurzanleitung zu Fertigkeiten + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kurze praktische Lernprogramme zu den wichtigsten Werkzeugen, nach Fertigkeiten geordnet. Wählen Sie eine Fertigkeit, um ihre Lernprogramme zu sehen. + List Tutorials Lernprogramme auflisten + Show Tutorial Lernprogramm anzeigen +@msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) +@msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) + # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren Deferred Effects Zurückgestellte Effekte diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..edf89b0cce 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6773,6 +6773,19 @@ breakText Glyphs Γλύφοι @plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν @msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last` +# Learn panel + Learn Εκμάθηση + Explore tutorials Εξερεύνηση οδηγών εκμάθησης + For you Για εσάς + Skills Δεξιότητες + Get started Πρώτα βήματα + Quick skill guide Γρήγορος οδηγός δεξιοτήτων + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Σύντομοι πρακτικοί οδηγοί για τα βασικά εργαλεία, ομαδοποιημένοι ανά δεξιότητα. Επιλέξτε μια δεξιότητα για να δείτε τους οδηγούς της. + List Tutorials Λίστα οδηγών εκμάθησης + Show Tutorial Εμφάνιση οδηγού εκμάθησης +@msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) +@msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) + # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή Deferred Effects Εφέ σε αναμονή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..0c0d394e26 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6764,6 +6764,19 @@ breakText Glyphs Glifos @plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last` +# Learn panel + Learn Aprender + Explore tutorials Explorar tutoriales + For you Para ti + Skills Habilidades + Get started Primeros pasos + Quick skill guide Guía rápida de habilidades + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriales prácticos breves sobre las herramientas esenciales, agrupados por habilidad. Elige una habilidad para ver sus tutoriales. + List Tutorials Enumerar tutoriales + Show Tutorial Mostrar tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados Deferred Effects Efectos aplazados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..432c21611b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6856,6 +6856,19 @@ breakText Glyphs Merkit @plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last` +# Learn panel + Learn Opi + Explore tutorials Tutustu opetusohjelmiin + For you Sinulle + Skills Taidot + Get started Aloitus + Quick skill guide Pikaopas taitoihin + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Lyhyitä käytännön opetusohjelmia tärkeimmistä työkaluista taidoittain ryhmiteltyinä. Valitse taito nähdäksesi sen opetusohjelmat. + List Tutorials Luettele opetusohjelmat + Show Tutorial Näytä opetusohjelma +@msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) +@msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet Deferred Effects Lykätyt tehosteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..cf5d6fed88 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6816,6 +6816,19 @@ breakText Glyphs Glyphes @plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées @msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last` +# Learn panel + Learn Apprendre + Explore tutorials Découvrir les tutoriels + For you Pour vous + Skills Compétences + Get started Prise en main + Quick skill guide Guide rapide des compétences + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Courts tutoriels pratiques sur les outils essentiels, regroupés par compétence. Choisissez une compétence pour voir ses tutoriels. + List Tutorials Lister les tutoriels + Show Tutorial Afficher le tutoriel +@msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés Deferred Effects Effets différés diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..06cc0b8140 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8262,6 +8262,19 @@ theme Appearance अपीयरेंस @plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं @msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में +# Learn panel + Learn सीखें + Explore tutorials ट्यूटोरियल देखें + For you आपके लिए + Skills कौशल + Get started शुरू करें + Quick skill guide त्वरित कौशल गाइड + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. आवश्यक टूल के लिए छोटे व्यावहारिक ट्यूटोरियल, कौशल के अनुसार समूहित। किसी कौशल के ट्यूटोरियल देखने के लिए उसे चुनें। + List Tutorials ट्यूटोरियल की सूची + Show Tutorial ट्यूटोरियल दिखाएँ +@msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) +@msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) + # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें Deferred Effects स्थगित इफ़ेक्ट्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..9b44d64404 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6805,6 +6805,19 @@ breakText Glyphs Glifák @plural {n} line cut|{n} lines cut {n} sor levágva @msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt +# Learn panel + Learn Tanulás + Explore tutorials Oktatóanyagok felfedezése + For you Önnek + Skills Készségek + Get started Első lépések + Quick skill guide Gyors készségkalauz + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Rövid gyakorlati oktatóanyagok az alapvető eszközökhöz, készségek szerint csoportosítva. Válasszon egy készséget az oktatóanyagai megtekintéséhez. + List Tutorials Oktatóanyagok listázása + Show Tutorial Oktatóanyag megjelenítése +@msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) +@msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) + # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése Deferred Effects Elhalasztott effektusok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..eb560e47cb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6559,6 +6559,19 @@ breakText Glyphs Glif @plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang @plural {n} line cut|{n} lines cut {n} baris dipotong +# Learn panel + Learn Belajar + Explore tutorials Jelajahi tutorial + For you Untuk Anda + Skills Keterampilan + Get started Memulai + Quick skill guide Panduan cepat keterampilan + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorial praktik singkat untuk alat-alat utama, dikelompokkan menurut keterampilan. Pilih keterampilan untuk melihat tutorialnya. + List Tutorials Daftar tutorial + Show Tutorial Tampilkan tutorial +@msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) + # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda Deferred Effects Efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..2c2008ae7b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6442,6 +6442,19 @@ breakText Glyphs Glifi @plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last` +# Learn panel + Learn Impara + Explore tutorials Esplora i tutorial + For you Per te + Skills Competenze + Get started Per iniziare + Quick skill guide Guida rapida alle competenze + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Brevi tutorial pratici sugli strumenti essenziali, raggruppati per competenza. Scegli una competenza per vederne i tutorial. + List Tutorials Elenca tutorial + Show Tutorial Mostra tutorial +@msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) + # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati Deferred Effects Effetti rimandati diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..047bd025b7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6507,6 +6507,19 @@ breakText Glyphs 字形 @plural {n} line cut|{n} lines cut {n} 行を切り詰め @msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください +# Learn panel + Learn 学ぶ + Explore tutorials チュートリアルを見る + For you おすすめ + Skills スキル + Get started はじめに + Quick skill guide クイックスキルガイド + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 基本ツールを学ぶ短い実践チュートリアルを、スキルごとにまとめています。スキルを選ぶとチュートリアルが表示されます。 + List Tutorials チュートリアルの一覧 + Show Tutorial チュートリアルを表示 +@msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) +@msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) + # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 Deferred Effects 保留中の効果 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..d5721a45bd 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6935,6 +6935,19 @@ font-script Japanese იაპონური @plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ +# Learn panel + Learn სწავლა + Explore tutorials გაკვეთილების დათვალიერება + For you თქვენთვის + Skills უნარები + Get started დაწყება + Quick skill guide უნარების სწრაფი გზამკვლევი + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. მოკლე პრაქტიკული გაკვეთილები ძირითადი ხელსაწყოებისთვის, დაჯგუფებული უნარების მიხედვით. აირჩიეთ უნარი მისი გაკვეთილების სანახავად. + List Tutorials გაკვეთილების სია + Show Tutorial გაკვეთილის ჩვენება +@msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) +@msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) + # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება Deferred Effects გადადებული ეფექტები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..d32d0b9bb3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6403,6 +6403,19 @@ breakText Glyphs 글리프 @plural {n} line cut|{n} lines cut 줄 {n}개 잘림 @msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 +# Learn panel + Learn 학습 + Explore tutorials 튜토리얼 살펴보기 + For you 추천 + Skills 기술 + Get started 시작하기 + Quick skill guide 빠른 기술 가이드 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 기본 도구를 익히는 짧은 실습 튜토리얼을 기술별로 모았습니다. 기술을 선택하면 해당 튜토리얼이 표시됩니다. + List Tutorials 튜토리얼 목록 + Show Tutorial 튜토리얼 표시 +@msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) +@msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) + # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 Deferred Effects 보류된 효과 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..adb810067d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6610,6 +6610,19 @@ breakText Glyphs Glyphs @plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd @plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort +# Learn panel + Learn Leren + Explore tutorials Tutorials verkennen + For you Voor jou + Skills Vaardigheden + Get started Aan de slag + Quick skill guide Snelgids voor vaardigheden + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktische tutorials voor de belangrijkste gereedschappen, gegroepeerd per vaardigheid. Kies een vaardigheid om de tutorials ervan te zien. + List Tutorials Tutorials weergeven + Show Tutorial Tutorial tonen +@msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) +@msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) + # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen Deferred Effects Uitgestelde effecten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..2998d39870 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6677,6 +6677,19 @@ breakText Glyphs Glify @plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` +# Learn panel + Learn Nauka + Explore tutorials Przeglądaj samouczki + For you Dla Ciebie + Skills Umiejętności + Get started Pierwsze kroki + Quick skill guide Krótki przewodnik po umiejętnościach + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krótkie praktyczne samouczki dotyczące podstawowych narzędzi, pogrupowane według umiejętności. Wybierz umiejętność, aby zobaczyć jej samouczki. + List Tutorials Wyświetl samouczki + Show Tutorial Pokaż samouczek +@msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) +@msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) + # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty Deferred Effects Odroczone efekty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..19c74b85ff 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6894,6 +6894,19 @@ swatch Yellow Green Verde-amarelado @plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` +# Learn panel + Learn Aprender + Explore tutorials Explorar tutoriais + For you Para você + Skills Habilidades + Get started Primeiros passos + Quick skill guide Guia rápido de habilidades + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriais práticos curtos das ferramentas essenciais, agrupados por habilidade. Escolha uma habilidade para ver os tutoriais dela. + List Tutorials Listar Tutoriais + Show Tutorial Mostrar Tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) + # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados Deferred Effects Efeitos Adiados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..1025eb9335 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6910,6 +6910,19 @@ shape Open Открытый @plural {n} line cut|{n} lines cut {n} строка обрезана|{n} строки обрезаны|{n} строк обрезано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last` +# Learn panel + Learn Обучение + Explore tutorials Обзор уроков + For you Для вас + Skills Навыки + Get started Начало работы + Quick skill guide Краткое руководство по навыкам + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткие практические уроки по основным инструментам, сгруппированные по навыкам. Выберите навык, чтобы увидеть его уроки. + List Tutorials Перечислить уроки + Show Tutorial Показать урок +@msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) +@msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты Deferred Effects Отложенные эффекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..c855ad450d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8306,6 +8306,19 @@ digits Hindi Hindi @plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade @msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` +# Learn panel + Learn Lär dig + Explore tutorials Utforska självstudier + For you För dig + Skills Färdigheter + Get started Kom igång + Quick skill guide Snabbguide till färdigheter + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korta praktiska självstudier för de viktigaste verktygen, ordnade efter färdighet. Välj en färdighet för att se dess självstudier. + List Tutorials Lista självstudier + Show Tutorial Visa självstudie +@msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) +@msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) + # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter Deferred Effects Uppskjutna effekter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..a3a2116a41 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6777,6 +6777,19 @@ breakText Glyphs Glifler @plural {n} line cut|{n} lines cut {n} satır kesildi @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli +# Learn panel + Learn Öğren + Explore tutorials Öğreticileri Keşfet + For you Sizin için + Skills Beceriler + Get started Başlarken + Quick skill guide Hızlı beceri rehberi + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Temel araçlar için becerilere göre gruplanmış kısa uygulamalı öğreticiler. Öğreticilerini görmek için bir beceri seçin. + List Tutorials Öğreticileri Listele + Show Tutorial Öğreticiyi Göster +@msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) +@msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile Deferred Effects Ertelenen Efektler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..9ca154c82c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6422,6 +6422,19 @@ breakText Glyphs Гліфи @plural {n} line cut|{n} lines cut {n} рядок обрізано|{n} рядки обрізано|{n} рядків обрізано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last` +# Learn panel + Learn Навчання + Explore tutorials Переглянути уроки + For you Для вас + Skills Навички + Get started Початок роботи + Quick skill guide Короткий посібник із навичок + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткі практичні уроки з основних інструментів, згруповані за навичками. Виберіть навичку, щоб побачити її уроки. + List Tutorials Перелічити уроки + Show Tutorial Показати урок +@msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) +@msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти Deferred Effects Відкладені ефекти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..1ae4d83b00 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6423,6 +6423,19 @@ breakText Glyphs Glyph @plural {n} line cut|{n} lines cut đã cắt {n} dòng @msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` +# Learn panel + Learn Học + Explore tutorials Khám phá hướng dẫn + For you Dành cho bạn + Skills Kỹ năng + Get started Bắt đầu + Quick skill guide Hướng dẫn nhanh về kỹ năng + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Các hướng dẫn thực hành ngắn cho những công cụ thiết yếu, xếp theo kỹ năng. Chọn một kỹ năng để xem các hướng dẫn của nó. + List Tutorials Liệt kê hướng dẫn + Show Tutorial Hiện hướng dẫn +@msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) +@msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) + # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn Deferred Effects Hiệu ứng bị hoãn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..bcdbd8421a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6659,6 +6659,19 @@ breakText Glyphs 字形 @plural {n} line cut|{n} lines cut 已截断 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 +# Learn panel + Learn 学习 + Explore tutorials 浏览教程 + For you 为你推荐 + Skills 技能 + Get started 入门 + Quick skill guide 快速技能指南 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 针对基本工具的简短实操教程,按技能分组。选择一项技能即可查看其教程。 + List Tutorials 列出教程 + Show Tutorial 显示教程 +@msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) +@msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) + # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 Deferred Effects 延迟的效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..5a87d476bd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6457,6 +6457,19 @@ breakText Glyphs 字符 @plural {n} line cut|{n} lines cut 已截斷 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 +# Learn panel + Learn 學習 + Explore tutorials 瀏覽教學課程 + For you 為你推薦 + Skills 技能 + Get started 入門 + Quick skill guide 快速技能指南 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 針對基本工具的簡短實作教學課程,依技能分組。選擇一項技能即可查看其教學課程。 + List Tutorials 列出教學課程 + Show Tutorial 顯示教學課程 +@msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) +@msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) + # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 Deferred Effects 延後的效果 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 78eb6149d1..b5bde30cd0 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -195,6 +195,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")), ("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")), ("frame", include_bytes!("../../../assets/icons/frame.svg")), + ("graduation-cap", include_bytes!("../../../assets/icons/graduation-cap.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("group", include_bytes!("../../../assets/icons/group.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..0ca31b80d6 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -81,6 +81,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R .or_else(|| crate::workspaces::run_command(app, id, p)) .or_else(|| crate::edit_toolbar::command(app, id, p)) .or_else(|| crate::custom_toolbars::command(app, id, p)) + .or_else(|| crate::panels::learn::command(app, id, p)) { return Some(r); } diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 2e45a88483..efc77cb5a5 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -89,6 +89,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> { "window.taskBar" => app.ui.task_bar, "window.pathfinder.compoundShapes" => app.ui.shape_modes_compound, "window.taskBar.pin" => app.ui.task_bar_place.pinned, + "window.learn" => crate::panels::learn::visible(&app.ui), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 0c46950f37..b5bb7dac3b 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -658,6 +658,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> { panel("Image Trace", "imageTrace"), panel("Info", "info"), panel("Layers", "layers"), + cp("Learn", "window.learn", json!({})), panel("Libraries", "libraries"), panel("Links", crate::panels::links::ID), panel("Magic Wand", "magicWand"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67c..edf18fcc3a 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -629,6 +629,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", ), + ( + "window.learn", + "Learn", + "", + "{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows", + ), + ( + "learn.list", + "List Tutorials", + "", + "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + ), + ( + "learn.show", + "Show Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1106406bc0..899f026398 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,6 +26,7 @@ pub mod history; pub mod image_trace; pub mod info; pub mod layers; +pub mod learn; pub mod libraries; pub mod library_panel; pub mod links; @@ -185,6 +186,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { css_properties::ID => css_properties::show(app, ui), three_d::ID => three_d::show(app, ui), script_console::ID => script_console::show(app, ui), + learn::ID => learn::show(app, ui), crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs new file mode 100644 index 0000000000..e49832c0da --- /dev/null +++ b/crates/ui-egui/src/panels/learn.rs @@ -0,0 +1,214 @@ +//! Learn panel (Window › Learn, and Explore tutorials at the right end of the app bar): short +//! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started +//! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each +//! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a +//! tutorial previews its numbered steps. Back goes up one level. `learn.list` lists the tutorials, +//! `learn.show` opens one and `window.learn` shows or hides the panel. + +pub mod catalog; + +use egui::{Sense, Stroke, Ui, vec2}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +pub const ID: &str = "learn"; +/// The height of a skill or tutorial row. +const ROW_HEIGHT: f32 = 28.0; + +/// The panel's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + ForYou, + Skills, +} + +/// What the panel shows: its tab and, on the Skills tab, the skill and the tutorial open. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct View { + pub tab: Tab, + pub skill: Option<&'static str>, + pub tutorial: Option<&'static str>, +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.horizontal(|ui| { + for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { + if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() { + app.ui.learn.tab = tab; + } + } + }); + crate::widgets::divider(ui); + match app.ui.learn.tab { + Tab::ForYou => for_you(app, ui), + Tab::Skills => skills(app, ui), + } +} + +/// The For you tab: Get started, with the Quick skill guide card. +fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::subheader(ui, tl!("Get started")); + ui.add_space(4.0); + let card = egui::Frame::NONE + .fill(t.panel_darker) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(egui::CornerRadius::same(t.radius)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + crate::icons::icon(ui, "graduation-cap", 18.0, t.text_strong); + ui.label(egui::RichText::new(tl!("Quick skill guide")).size(13.0).color(t.text_strong)); + }); + ui.add_space(4.0); + ui.add(egui::Label::new( + egui::RichText::new(tl!("Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials.")) + .size(11.5) + .color(t.text_dim), + )); + }); + let resp = ui.interact(card.response.rect, ui.scope_id().with("quick-skill-guide"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Quick skill guide"))); + if resp.clicked() { + app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + } +} + +/// The Skills tab: the skills, a skill's tutorials, or a tutorial's steps. +fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { + let open = app.ui.learn.tutorial.and_then(catalog::tutorial); + if let Some((skill, tutorial)) = open { + if back(ui) { + app.ui.learn.skill = Some(skill.id); + app.ui.learn.tutorial = None; + } + steps(ui, tutorial); + return; + } + if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { + if back(ui) { + app.ui.learn.skill = None; + } + crate::widgets::subheader(ui, skill.title); + for tutorial in skill.tutorials { + if row(ui, tutorial.title, None).clicked() { + app.ui.learn.tutorial = Some(tutorial.id); + } + } + return; + } + for skill in catalog::SKILLS { + if row(ui, skill.title, Some(skill.tutorials.len())).clicked() { + app.ui.learn.skill = Some(skill.id); + } + } +} + +/// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked. +fn back(ui: &mut Ui) -> bool { + crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() +} + +/// A tutorial's title and its numbered steps. +fn steps(ui: &mut Ui, tutorial: &catalog::Tutorial) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); + ui.add_space(4.0); + for (i, step) in tutorial.steps.iter().enumerate() { + ui.horizontal_top(|ui| { + ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + }); + ui.add_space(2.0); + } +} + +/// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the +/// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. +fn row(ui: &mut Ui, title: &str, tutorials: Option<usize>) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + let mut left = rect.left() + 6.0; + let mut right = rect.right() - 6.0; + let chevron = egui::Rect::from_center_size(egui::pos2(right - 6.0, rect.center().y), vec2(12.0, 12.0)); + crate::icons::paint(ui, "chevron-right", chevron, t.text_dim); + right = chevron.left() - 6.0; + if let Some(total) = tutorials { + ui.painter().circle_stroke(egui::pos2(left + 8.0, rect.center().y), 7.0, Stroke::new(2.0, t.border)); + left += 24.0; + let count = ui.painter().layout_no_wrap(format!("0/{total}"), egui::FontId::proportional(11.5), t.text_dim); + right -= count.size().x; + ui.painter().galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim); + right -= 6.0; + } + let name = ui.painter().layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY); + let clip = egui::Rect::from_min_max(egui::pos2(left, rect.top()), egui::pos2(right.max(left), rect.bottom())); + ui.painter().with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// The Learn panel's UI commands: `window.learn`, `learn.list` and `learn.show`. +pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> { + Some(match id { + "window.learn" => toggle(app, p), + "learn.list" => Ok(list()), + "learn.show" => open(app, p), + _ => return None, + }) +} + +/// Whether the panel can be seen: it is the front panel, the dock is not hidden and no full screen +/// mode hides the panels. +pub(crate) fn visible(ui: &crate::state::UiState) -> bool { + ui.dock && ui.screen_mode < 3 && crate::panel_docking::showing(ui, ID) +} + +/// `window.learn {show?}`: show the panel (true), hide it (false) or toggle it (omitted), as the +/// app bar's Explore tutorials button and Window › Learn do → whether it shows ([`visible`]). +/// Showing it also shows a hidden dock; in a full screen mode it makes the panel the front one. +fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { + let shown = visible(&app.ui); + let show = crate::menus::opt_bool(p, "show")?.unwrap_or(!shown); + if show && !shown { + crate::panel_docking::reveal_panel(app, ID)?; + } else if !show && shown { + app.run("window.panel", json!({ "panel": ID, "toggle": true }))?; + } + Ok(json!(visible(&app.ui))) +} + +/// `learn.list`: every skill with its tutorials and their step counts. +fn list() -> Value { + let skills = catalog::SKILLS.iter().map(|s| { + let tutorials: Vec<Value> = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect(); + json!({"id": s.id, "title": s.title, "tutorials": tutorials}) + }); + json!({ "skills": skills.collect::<Vec<_>>() }) +} + +/// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the +/// tutorial. A missing or unknown id is an error and changes nothing. +fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { + let key = "tutorial"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + let steps: Vec<&str> = tutorial.steps.iter().map(|s| s.text).collect(); + Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs new file mode 100644 index 0000000000..b1ffd897d7 --- /dev/null +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -0,0 +1,185 @@ +//! The Learn panel's tutorials, VectorCraft's own short lessons, grouped by skill. The text is +//! English data: the panel shows it as written in every interface language. + +/// One step of a tutorial: what to do, and the tool it chooses or the command it runs, if any. +pub struct Step { + pub text: &'static str, + /// A tool id ([`vectorcraft_tools::catalog::tool_info`]) the step names. + pub tool: Option<&'static str>, + /// A command id the step names. + pub command: Option<&'static str>, +} + +/// A tutorial: an id for `learn.show`, its title and its numbered steps. +pub struct Tutorial { + pub id: &'static str, + pub title: &'static str, + pub steps: &'static [Step], +} + +/// A skill and its tutorials, in the order the panel lists them. +pub struct Skill { + pub id: &'static str, + pub title: &'static str, + pub tutorials: &'static [Tutorial], +} + +/// A step that names no tool or command. +const fn say(text: &'static str) -> Step { + Step { text, tool: None, command: None } +} + +/// A step that uses tool `id`. +const fn tool(text: &'static str, id: &'static str) -> Step { + Step { text, tool: Some(id), command: None } +} + +/// A step that runs command `id`. +const fn command(text: &'static str, id: &'static str) -> Step { + Step { text, tool: None, command: Some(id) } +} + +/// Every skill, in the order the Skills tab lists them. +pub const SKILLS: &[Skill] = &[ + Skill { + id: "essentials", + title: "Best of VectorCraft", + tutorials: &[ + Tutorial { + id: "draw-and-color", + title: "Draw and color a shape", + steps: &[ + tool("Choose the Rectangle tool in the toolbar.", "rectangle"), + say("Drag on the artboard to draw a rectangle."), + say("With the rectangle still selected, click a color in the Swatches panel to fill it."), + command("Click the Stroke box under the tools, then click another swatch to color the outline.", "paint.toggleActive"), + ], + }, + Tutorial { + id: "combine-shapes", + title: "Combine shapes into one", + steps: &[ + tool("Choose the Ellipse tool and draw two circles that overlap.", "ellipse"), + tool("Choose the Selection tool and drag across both circles to select them.", "selection"), + tool("Choose the Shape Builder tool and drag across the parts you want to merge.", "shapeBuilder"), + ], + }, + Tutorial { + id: "add-headline", + title: "Add a headline", + steps: &[ + tool("Choose the Type tool and click on the artboard.", "type"), + say("Type a few words, then press Escape to finish."), + say("Pick a font and a size in the Character panel."), + ], + }, + ], + }, + Skill { + id: "draw", + title: "Draw and build", + tutorials: &[ + Tutorial { + id: "pen-paths", + title: "Draw paths with the Pen", + steps: &[ + tool("Choose the Pen tool.", "pen"), + say("Click to place corner points, or drag to pull out a curve."), + say("Click the first point again to close the shape, or press Enter to leave it open."), + ], + }, + Tutorial { + id: "smooth-curves", + title: "Draw smooth curves", + steps: &[ + tool("Choose the Curvature tool.", "curvature"), + say("Click to place points: the path bends smoothly through each one."), + tool("Choose the Direct Selection tool and drag a point to reshape the curve.", "directSelection"), + ], + }, + Tutorial { + id: "pencil-sketch", + title: "Sketch with the Pencil", + steps: &[ + tool("Choose the Pencil tool.", "pencil"), + say("Drag to draw a freehand line."), + tool("Choose the Smooth tool and drag along the line to even it out.", "smooth"), + ], + }, + ], + }, + Skill { + id: "color", + title: "Apply color", + tutorials: &[ + Tutorial { + id: "fill-and-stroke", + title: "Fill and stroke", + steps: &[ + tool("Select an object with the Selection tool.", "selection"), + say("Click the Fill box under the tools, then pick a color in the Color panel."), + command("Click the double arrow beside the boxes to swap the fill and the stroke.", "paint.swap"), + ], + }, + Tutorial { + id: "gradient", + title: "Add a gradient", + steps: &[ + tool("Select an object with the Selection tool.", "selection"), + tool("Choose the Gradient tool and click the object to give it a gradient.", "gradient"), + say("Drag across the object to set the gradient's direction and length."), + ], + }, + Tutorial { + id: "copy-colors", + title: "Copy colors from other art", + steps: &[ + tool("Select the object to color with the Selection tool.", "selection"), + tool("Choose the Eyedropper tool and click the art whose colors you want.", "eyedropper"), + ], + }, + ], + }, + Skill { + id: "type", + title: "Set type", + tutorials: &[ + Tutorial { + id: "area-type", + title: "Flow text into a shape", + steps: &[ + tool("Draw a rectangle for the text to fill.", "rectangle"), + tool("Choose the Area Type tool and click the rectangle's edge.", "areaType"), + say("Type or paste your text: it wraps inside the shape."), + ], + }, + Tutorial { + id: "type-on-path", + title: "Type along a path", + steps: &[ + tool("Draw a curved path with the Pen tool.", "pen"), + tool("Choose the Type on a Path tool and click the path.", "typeOnPath"), + say("Type your text: it follows the path."), + ], + }, + Tutorial { + id: "outline-type", + title: "Turn type into shapes", + steps: &[ + tool("Select a text object with the Selection tool.", "selection"), + command("Choose Type › Create Outlines to turn its letters into paths.", "type.createOutlines"), + ], + }, + ], + }, +]; + +/// The skill whose id is `id`. +pub fn skill(id: &str) -> Option<&'static Skill> { + SKILLS.iter().find(|s| s.id == id) +} + +/// The tutorial whose id is `id`, with its skill. +pub fn tutorial(id: &str) -> Option<(&'static Skill, &'static Tutorial)> { + SKILLS.iter().find_map(|s| s.tutorials.iter().find(|t| t.id == id).map(|t| (s, t))) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs new file mode 100644 index 0000000000..d4dbf8f00b --- /dev/null +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -0,0 +1,231 @@ +use std::collections::HashSet; + +use egui::accesskit::Node; +use serde_json::json; +use vectorcraft_engine::Session; + +use super::{ID, Tab, View, catalog, show}; +use crate::VectorcraftApp; +use crate::tests_labels::painted_text; + +const SCREEN: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 800.0)); + +fn app() -> VectorcraftApp { + crate::i18n::set_current(crate::i18n::Lang::EN); + VectorcraftApp::new(Session::new(), Default::default()) +} + +/// One frame of `draw` with screen-reader nodes on → the nodes. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<Node> { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(SCREEN), ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +/// The center of the node labeled `name`. +fn center(nodes: &[Node], name: &str) -> Option<egui::Pos2> { + center_where(nodes, |label| label == name) +} + +/// The center of the first node whose label `pick` takes. +fn center_where(nodes: &[Node], pick: impl Fn(&str) -> bool) -> Option<egui::Pos2> { + let b = nodes.iter().find(|n| n.label().is_some_and(&pick))?.bounds()?; + Some(egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) +} + +/// Click the node labeled `name` in what `draw` draws. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + frame(app, ctx, vec![], draw); + let at = center(&frame(app, ctx, vec![], draw), name).unwrap_or_else(|| panic!("{name} is drawn")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at), press(true)], draw); + frame(app, ctx, vec![press(false)], draw); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + ctx +} + +fn app_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + crate::chrome::app_bar(app, ui); +} + +fn shown(app: &VectorcraftApp) -> bool { + crate::panel_docking::showing(&app.ui, ID) +} + +/// Every skill has tutorials and every tutorial steps; ids are unique; every tool and command a +/// step names exists. +#[test] +fn the_catalog_is_valid() { + let mut skills = HashSet::new(); + let mut tutorials = HashSet::new(); + assert!(!catalog::SKILLS.is_empty()); + for skill in catalog::SKILLS { + assert!(skills.insert(skill.id), "skill id {} is used twice", skill.id); + assert!(!skill.title.is_empty() && !skill.tutorials.is_empty(), "skill {} is empty", skill.id); + for tutorial in skill.tutorials { + assert!(tutorials.insert(tutorial.id), "tutorial id {} is used twice", tutorial.id); + assert!(!tutorial.title.is_empty() && !tutorial.steps.is_empty(), "tutorial {} is empty", tutorial.id); + for step in tutorial.steps { + assert!(step.text.ends_with('.'), "{}: {:?}", tutorial.id, step.text); + if let Some(tool) = step.tool { + assert!(vectorcraft_tools::catalog::tool_info(tool).is_some(), "{}: no tool {tool}", tutorial.id); + } + if let Some(cmd) = step.command { + let known = + crate::menus::UI_COMMANDS.iter().any(|c| c.0 == cmd) || vectorcraft_engine::cmd::command_specs().iter().any(|c| c.id == cmd); + assert!(known, "{}: no command {cmd}", tutorial.id); + } + } + } + } +} + +/// Explore tutorials at the right end of the app bar shows the Learn panel, and hides it again. +#[test] +fn explore_tutorials_on_the_app_bar_shows_and_hides_the_learn_panel() { + let mut app = app(); + let ctx = context(); + assert!(!shown(&app)); + let nodes = frame(&mut app, &ctx, vec![], app_bar); + let search = center_where(&nodes, |label| label.starts_with("Search commands and tools")); + let learn = center(&nodes, "Explore tutorials"); + let (search, learn) = (search.expect("the search icon shows"), learn.expect("the app bar has Explore tutorials")); + assert!(learn.x < search.x && (learn.y - search.y).abs() < 1.0, "beside the search icon: {learn:?}, {search:?}"); + click(&mut app, &ctx, "Explore tutorials", app_bar); + assert!(shown(&app), "{}", app.ui.status); + click(&mut app, &ctx, "Explore tutorials", app_bar); + assert!(!shown(&app)); +} + +/// The command and params of Window › Learn. +fn window_learn() -> (&'static str, serde_json::Value) { + use crate::menus::Item; + let window = crate::menus::menu_tree().into_iter().find(|(title, _)| *title == "Window").map(|(_, items)| items).unwrap(); + window + .iter() + .find_map(|item| match item { + Item::Cmd("Learn", cmd, p) => Some((*cmd, p.clone())), + _ => None, + }) + .expect("Window › Learn") +} + +/// Window › Learn shows and hides the panel, as `window.learn` does. +#[test] +fn window_learn_shows_and_hides_the_panel() { + let mut app = app(); + let (cmd, params) = window_learn(); + app.run(cmd, params.clone()).unwrap(); + assert!(shown(&app)); + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(true), "Window › Learn is checked"); + app.run(cmd, params.clone()).unwrap(); + assert!(!shown(&app)); + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(false)); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true)); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true), "shown stays shown"); + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(app.run("window.learn", json!({"show": "yes"})).is_err()); + assert!(!shown(&app)); +} + +/// With the dock hidden, Learn as the front panel is not visible: `window.learn`, Window › Learn +/// and Explore tutorials show the dock instead of closing the panel. +#[test] +fn window_learn_shows_a_hidden_dock() { + let mut app = app(); + let (cmd, params) = window_learn(); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true)); + app.ui.dock = false; + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(false), "Window › Learn is unchecked"); + assert_eq!(app.run("window.learn", json!({"show": false})).unwrap(), json!(false)); + assert!(shown(&app) && !app.ui.dock, "hiding a panel that cannot be seen changes nothing"); + app.run(cmd, params).unwrap(); + assert!(app.ui.dock && shown(&app), "Window › Learn shows the dock"); + // A full screen mode hides the panels: the panel stays the front one and shows when it ends. + app.ui.screen_mode = 3; + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(shown(&app)); + app.ui.screen_mode = 0; + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(!shown(&app)); +} + +/// The For you tab's Quick skill guide card opens the Skills tab, which lists every skill with its +/// count of tutorials. +#[test] +fn the_skills_tab_lists_every_skill() { + let mut app = app(); + let ctx = context(); + let text = painted_text(&mut app, show); + assert!(text.contains("Get started") && text.contains("Quick skill guide"), "{text}"); + click(&mut app, &ctx, "Quick skill guide", show); + assert_eq!(app.ui.learn.tab, Tab::Skills); + let text = painted_text(&mut app, show); + for skill in catalog::SKILLS { + assert!(text.contains(skill.title), "{} in {text}", skill.title); + assert!(text.contains(&format!("0/{}", skill.tutorials.len())), "{text}"); + } + let skill = &catalog::SKILLS[1]; + click(&mut app, &ctx, skill.title, show); + let text = painted_text(&mut app, show); + for tutorial in skill.tutorials { + assert!(text.contains(tutorial.title), "{} in {text}", tutorial.title); + } +} + +/// Choosing a tutorial previews its numbered steps; Back goes to its skill, then to the skills. +#[test] +fn a_tutorial_previews_its_steps() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }; + click(&mut app, &ctx, tutorial.title, show); + assert_eq!(app.ui.learn.tutorial, Some(tutorial.id)); + let text = painted_text(&mut app, show); + for (i, step) in tutorial.steps.iter().enumerate() { + assert!(text.contains(&format!("{}.", i + 1)) && text.contains(step.text), "{} in {text}", step.text); + } + click(&mut app, &ctx, "Back", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + click(&mut app, &ctx, "Back", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }); +} + +/// `learn.show` opens the panel on a tutorial's steps; `learn.list` lists every tutorial. +#[test] +fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { + let mut app = app(); + let shown_tutorial = app.run("learn.show", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(shown_tutorial["skill"], "color"); + assert_eq!(shown_tutorial["steps"].as_array().map(Vec::len), Some(3)); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") }); + let list = app.run("learn.list", json!({})).unwrap(); + let skills = list["skills"].as_array().unwrap(); + assert_eq!(skills.len(), catalog::SKILLS.len()); + let count: usize = skills.iter().map(|s| s["tutorials"].as_array().map_or(0, Vec::len)).sum(); + assert_eq!(count, catalog::SKILLS.iter().map(|s| s.tutorials.len()).sum::<usize>()); + assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); +} + +/// A missing, mistyped or unknown tutorial id is an error and leaves the panel as it was. +#[test] +fn learn_show_rejects_a_missing_or_unknown_tutorial() { + let mut app = app(); + let before = app.ui.learn.clone(); + for (params, error) in [ + (json!({}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": 3}), "`tutorial` must be a string"), + (json!({"tutorial": "nope"}), "unknown tutorial `nope` (see learn.list)"), + ] { + assert_eq!(app.run("learn.show", params).unwrap_err(), error); + assert_eq!(app.ui.learn, before); + assert!(!shown(&app)); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 59553120c8..d454da1457 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -209,6 +209,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), (crate::panels::script_console::ID, "Script Console", "message-circle"), + (crate::panels::learn::ID, "Learn", "graduation-cap"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), (crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"), @@ -401,6 +402,9 @@ pub struct UiState { /// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs. #[serde(skip)] pub find_font_in: String, + /// The Learn panel's tab, skill and tutorial, kept while the app runs. + #[serde(skip)] + pub learn: crate::panels::learn::View, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -1017,6 +1021,7 @@ impl Default for UiState { transient_focus: false, color_settings_scroll: false, find_font_in: String::new(), + learn: Default::default(), control_bar: false, control_bar_bottom: false, toolbar: true, diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..e898e2ddb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2266,6 +2266,26 @@ kept with the workspace, and built-in workspaces and workspaces saved without it {"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} ``` +## Learn panel + +Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide +the Learn panel. Its For you tab holds a Get started section with a Quick skill guide card; choosing the card opens +the Skills tab. The Skills tab lists VectorCraft's own skills, each with its number of tutorials and an empty progress +ring. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and Back goes up one level. +The tutorial text is English. `window.learn {show?}` shows the panel (`show: true`), hides it (`show: false`) or +toggles it (`show` omitted or null), and answers whether it shows; Window › Learn and Explore tutorials run it. +The panel counts as hidden while the dock is hidden or a full screen mode hides the panels. Showing it shows a hidden +dock; in a full screen mode it makes Learn the front panel, which shows when the mode ends, and answers false. Any +other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab +lists them, where `steps` is the number of steps. `learn.show {tutorial}` shows the panel on its Skills tab with that +tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error +and changes nothing. Behavior reference: +[Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). + +```json +{"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} +``` + ## Ruler guides Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..c08d70217d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -801,6 +801,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). + - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..35eec8335b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -371,6 +371,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`; `panels::learn::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | From 50efa1630380679dd4a76c7392283c7971e27667 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:16:17 -0800 Subject: [PATCH 038/126] MCP: a hidden library keeps the id it was first shown under A library loaded from outside a confined client's read roots keeps its `loaded/<file name>` id for the session, in ui.inspect, ui.docking and the library commands, so an id taken from an earlier reply still finds the same library after another library with the same file name is loaded. swatch.library.copyToUser leaves out `path` when the file is outside the read roots, as the saves do. The ids come from the engine's concealed_library_ids, which now reads a path written with either separator, counts another platform's absolute path as outside the roots, and keeps each id in the session. --- crates/engine/src/cmd/swatchlib.rs | 60 +++++++++++++++++------- crates/engine/src/lib.rs | 3 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/library_roots.rs | 37 +++++++++++++-- crates/ui-egui/src/tests/automation.rs | 37 +++++++++++++++ docs/mcp.md | 22 +++++---- docs/target-app-parity.md | 2 +- 7 files changed, 131 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index bc48f9d323..7934de2c0f 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Copy to User Defined", ["Window", "Swatch Libraries"], None, - "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}", has_user_folder, copy_to_user ), @@ -368,7 +368,9 @@ impl<L: LibraryFile> Libraries<L> { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { - // Outside the request's read roots: the error names no path. + // The file's bytes are read: outside the request's read roots, the error names no + // path. A document's swatches (the arm below) are written from the loaded library, + // and the document is not read again. if !readable(&p) { return Err(bad(cmd, "the library's file is outside the read roots")); } @@ -486,11 +488,15 @@ fn written_elsewhere(path: &str) -> bool { /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is /// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then -/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free -/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The -/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows -/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. +/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers +/// to). When another id already is or shows that id, the next free +/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library +/// keeps the id it was first shown under for the rest of the session, so an id a client was given +/// finds the same library after another one with the same file name is loaded; only a library that +/// shows that id itself (one loaded later without a file under that name) moves it to the next free +/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of +/// their full ids, then `others`, in the same order. Without roots in force, none. pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -515,16 +521,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin } } } + let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner()); + let mut kept = vec![None; hidden.len()]; + for (slot, (id, _)) in kept.iter_mut().zip(&hidden) { + if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) { + taken.insert(shown.clone()); + *slot = Some(shown.clone()); + } + } + // A new id is never one given to another library earlier in the session. + let mut given: BTreeSet<String> = assigned.values().cloned().collect(); hidden .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); + .zip(kept) + .map(|((id, name), kept)| { + let shown = kept.unwrap_or_else(|| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) || given.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + given.insert(shown.clone()); + assigned.insert(id.clone(), shown.clone()); + shown + }); (id, shown) }) .collect() @@ -759,8 +781,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let path = s.swatch_libraries.shown_path(&info.id); - Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) + let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied}); + // As the saves with `user: true`: no `path` when the request may not read the file. + if let Some(path) = s.swatch_libraries.shown_path(&info.id) { + out["path"] = json!(path); + } + Ok(out) } /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 099f99abdf..66078332bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,9 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The id each loaded library outside a request's read roots has been shown under, by its own id + /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session. + shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d39979524a..464248aad7 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,7 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + shown_library_ids: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 30a8776b35..000f0e7532 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,26 +214,53 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let mine = format!("loaded/{}", text(&inside)); let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine}))); assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}"); - assert_eq!(copied["path"], Value::Null, "{copied}"); + assert_eq!(copied.get("path"), None, "{copied}"); let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"}))); assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}"); for reply in [&added, &copied, &saved] { assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}"); } - // A library loaded from data under the file name keeps its id; each concealed one takes the next - // free one, so no two rows share an id. + // A library loaded from data under the file name keeps its id; the concealed one shown under that + // id takes the next free one, the other keeps its id, and no two rows share an id. let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string(); run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"})); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}"); // Copied into the user library folder, it is still found by that id, which no other library takes. confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"}))); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}"); // The person sees the folder. let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"})); assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches")))); let _ = std::fs::remove_dir_all(base); } + +/// A concealed library keeps the id a client was shown when another library with the same file name, +/// whose full id sorts before it, is loaded later: the id still finds the first library. +#[test] +fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() { + let base = folder("keeps-id"); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches")); + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + (SWATCHES.add)(&mut s, "First"); + run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"})); + (SWATCHES.add)(&mut s, "Second"); + run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"})); + let shown = "loaded/Kit.vcswatches"; + let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone(); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}"); + run(&mut s, "swatch.library.load", json!({"path": text(&later)})); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); + assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}"); + let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))})); + let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown}))); + assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 754d5489fd..b1a1fbce75 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -787,6 +787,43 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// A library panel command given a hidden library's shown id opens the library and answers with +/// that id, not the library's path. +#[test] +fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() { + let (mut app, tx, base) = confined("panel-shown-id"); + let path = base.join("outside/x/Kit.gpl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}"); + assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id"); + // The panel id it answers with names the panel in the panel commands. + let panel = r["result"]["panel"].clone(); + assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + // Closed, the panel isn't found, and the error names it by the id given. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() diff --git a/docs/mcp.md b/docs/mcp.md index 802d8d9943..909dc91a40 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -143,11 +143,13 @@ the suite) keep the agent to the folders you give it: params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same - file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of - their full ids (the libraries loaded in this session first, then those only the UI state names), and each one - keeps its id in both replies and in the library commands below. A hidden library skips an id that a library - loaded without a path already has. The other entries keep their order, and the app's own state is not - changed. A request may name such a library or its panel by the id + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they + are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the + libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest + of the session in both replies and in the library commands below, also after another library with the same + file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a + library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next + free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path @@ -168,9 +170,13 @@ the suite) keep the agent to the folders you give it: `(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`, `symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the - others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file - is outside the read roots without naming the file, and it and the library saves with `user: true` leave out - `path` when the user library folder is outside the read roots. + others that take `library`), whose replies show it too. Over the control channel, `window.swatchLibrary` and + the other library panel commands answer with that id and its panel's id (`library:swatches:loaded/<file name>`), + and `ui.libraryPanel` and the `window.panel.*` commands take that panel id in `panel`, `anchor`, `before`, + `onto`, `above` and `below`. `swatch.library.copyToUser` refuses to copy a library file outside the read roots + without naming the file (the swatches of a document loaded from there are written from the loaded library, as + for a library loaded from data, and the document is not read again), and it and the library saves with + `user: true` leave out `path` when the user library folder is outside the read roots. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9f9a1d5cb..45f7d1721f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -750,7 +750,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From db97cc1a8faaa751b370376703f9f2021d7610a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:35:33 -0800 Subject: [PATCH 039/126] JavaScript: a bare E4X name inside with or a filter can be assigned With Options::e4x set, an E4X name standing alone (@a, @ns::a, @[expr], ns::a, *) is now a valid target for =, compound assignment, ++, --, delete and for/for each loops, as well as a read. The target is the nearest XML or XMLList value in scope whose [[HasProperty]] accepts the name (ECMA-357 section 11.1). The name is evaluated once, and the target resolves before the right-hand side runs. When no XML value in scope holds the name, a read or write throws a ReferenceError and creates no child or attribute. A read no longer falls back to the first XML value in scope, so a filter such as list.(@id == '1') throws when an item has no @id and no outer XML value holds one, instead of treating that item as not matching. --- crates/js/src/ast.rs | 3 +- crates/js/src/bytecode.rs | 6 ++- crates/js/src/compiler.rs | 31 ++++++++++++- crates/js/src/interp.rs | 1 + crates/js/src/parser.rs | 7 +-- crates/js/src/xml.rs | 38 ++++++++++------ crates/js/src/xml_tests.rs | 89 ++++++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 8 files changed, 156 insertions(+), 20 deletions(-) diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index 5a4363108a..8d9ffc06c0 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -187,7 +187,8 @@ pub enum Expr { qualifier: Option<XmlQualifier>, name: XmlNamePart, }, - /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope + /// that holds it, and also an assignment, update or `delete` target. XmlScopeName(ExprId), /// `object..name`. XmlDescendants { diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 3092530d46..72bd9ef90e 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -160,8 +160,11 @@ pub(crate) enum Op { parts: u32, list: bool, }, - /// `name` → the value of the name on the nearest XML value in scope. + /// `name` → the value of the name on the nearest XML value in scope that holds it. XmlScopeGet, + /// `name` → `object name`: the nearest XML value in scope that holds the name, for an + /// assignment, update or `delete`. + XmlScopeRef, /// `object` → the filter's state. XmlFilterStart, /// `state` → `state item`, or jumps with `state` when the items are used up. @@ -308,6 +311,7 @@ fn effect(op: Op, finally_tables: &[Vec<u32>]) -> Result<Effect, String> { Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), + Op::XmlScopeRef => e(1, 2), Op::ForEachValue => e(2, 2), Op::SetDefaultNamespace => e(1, 0), }) diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 071e99a60e..effb475f92 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -538,6 +538,11 @@ impl<'a> FnCompiler<'a> { self.emit(Op::Rot3); self.emit(Op::SetIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::Rot3); + self.emit(Op::SetIndex); + } _ => { self.expr(target)?; self.emit(Op::Pop); @@ -854,6 +859,15 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Pushes the nearest XML value in scope that holds the E4X name `name` of the expression + /// `id`, then the name, as an assignment, update or `delete` uses them. + fn xml_scope_ref(&mut self, id: ExprId, name: ExprId) -> CResult<()> { + self.expr(name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeRef); + Ok(()) + } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { for part in parts { match part { @@ -1096,6 +1110,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::DeleteIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(arg, *name)?; + self.emit(Op::DeleteIndex); + } _ => { self.expr(arg)?; self.emit(Op::Pop); @@ -1202,6 +1220,13 @@ impl<'a> FnCompiler<'a> { self.emit(Op::GetIndex); } } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + if op.is_some() { + self.emit(Op::Dup2); + self.emit(Op::GetIndex); + } + } _ => { self.expr(target)?; if op.is_none() { @@ -1222,7 +1247,7 @@ impl<'a> FnCompiler<'a> { let n = self.name_key(key_of(&property.name)); self.emit(Op::SetMember(n)); } - Some(Expr::Index { .. }) => self.emit(Op::SetIndex), + Some(Expr::Index { .. } | Expr::XmlScopeName(_)) => self.emit(Op::SetIndex), _ => self.throw_invalid_target(), } Ok(()) @@ -1246,6 +1271,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::IncIndex { prefix, dec }); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::IncIndex { prefix, dec }); + } _ => { self.expr(target)?; self.emit(Op::ToNumber); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..b8213fbaf3 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -922,6 +922,7 @@ impl Interp { Op::XmlEscape(kind) => self.op_xml_escape(kind), Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlScopeRef => self.op_xml_scope_ref(), Op::XmlFilterStart => self.op_xml_filter_start(), Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 3c2ab3ed30..fa201c58ff 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -873,8 +873,9 @@ impl<'s> Parser<'s> { } /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code - /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning - /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + /// neither `eval` nor `arguments`), a property, an E4X name standing alone (`@a`, `ns::a`; + /// ECMA-357 section 11.1), or outside strict mode code a call (assigning to its result fails + /// at run time, as ECMA-262 2015 Annex B and web browsers have it). fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { let inner = self.ast.unparenthesized(expr); match self.ast.expr(inner) { @@ -884,7 +885,7 @@ impl<'s> Parser<'s> { } Ok(()) } - Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Member { .. } | Expr::Index { .. } | Expr::XmlScopeName(_)) => Ok(()), Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), } diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9dc6d71f85..9104ba83f7 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -2797,27 +2797,24 @@ impl Interp { self.replace(n, value) } - pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let n = self.xml_name_only(&name)?; + /// The nearest XML or XMLList value in scope whose [[HasProperty]] accepts `n`, or a + /// ReferenceError when none does (ECMA-357 section 11.1). + fn xml_scope_holder(&mut self, n: &NameRef) -> JsResult<ObjId> { let mut cur = Some(self.frame()?.env); - let mut first = None; + let mut any_xml = false; while let Some(e) = cur { match self.heap.env(e) { Some(Env::Decl { outer, .. }) => cur = *outer, Some(&Env::Obj { object, outer, .. }) => { let v = Value::Object(object); if self.is_xml(&v) { - if first.is_none() { - first = Some(object); - } + any_xml = true; let found = match self.classify(&v) { - Xv::Node(x) => self.node_has_name(x, &n)?, - _ => self.list_has_name(object, &n)?, + Xv::Node(x) => self.node_has_name(x, n)?, + _ => self.list_has_name(object, n)?, }; if found { - first = Some(object); - break; + return Ok(object); } } cur = outer; @@ -2825,13 +2822,26 @@ impl Interp { None => return Err(Abrupt::Internal("environment out of range".into())), } } - let Some(object) = first else { - return Err(self.throw(ErrorType::Reference, "an XML name is used outside any XML value")); - }; + let message = if any_xml { "no XML value in scope has the XML name" } else { "an XML name is used outside any XML value" }; + Err(self.throw(ErrorType::Reference, message)) + } + + pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; let v = self.xml_select(object, &n)?; self.replace(1, Value::Object(v)) } + pub(crate) fn op_xml_scope_ref(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; + self.replace(1, Value::Object(object))?; + self.push(name) + } + pub(crate) fn op_xml_filter_start(&mut self) -> JsResult<()> { let base = self.peek(0)?; let input = match self.classify(&base) { diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index cfe508a483..96ac9c3aed 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -636,6 +636,95 @@ fn a_scope_name_skips_a_list_without_that_name() { truth("var outer = <o id='1'/>; var inner = <><i id='2'/></>; with (outer) with (inner) { @id == '2' }"); } +#[test] +fn bare_attribute_assignment_uses_nearest_holder() { + truth("var x = <a b='1'/>; with (x) { @b = '2'; } x.@b == '2'"); + truth("var outer = <o id='1'/>; var inner = <i/>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var outer = <o id='1'/>; var inner = <i id='2'/>; with (outer) with (inner) { @id = '9'; } outer.@id == '1' && inner.@id == '9'"); + truth("var outer = <o id='1'/>; var inner = <><i/></>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var x = <a b='1'/>; with (x) with ({ b: 7 }) { @b = '3'; } x.@b == '3'"); + truth("with (<a b='1'/>) { (@b = 5) === 5 }"); + truth("var x = <a b='1' c='2'/>; with (x) { @b = @c = 'v'; } x.@b == 'v' && x.@c == 'v'"); + truth("var n = 0; var x = <a b='1'/>; with (x) { @[(n++, 'b')] = 'z'; } n === 1 && x.@b == 'z' && x.attributes().length() === 1"); + truth("var x = <a><c>1</c></a>; with (x) { *::c = '2'; } x.c == '2' && x.*.length() === 1"); + truth("var x = <a b='1'/>; x.@b = '4'; x.@b == '4'"); +} + +#[test] +fn bare_qualified_assignment_keeps_namespace() { + truth( + "var ns = new Namespace('u'); var x = <a xmlns:p='u'><p:c>1</p:c><c>0</c></a>; with (x) { ns::c = '2'; } \ + x.ns::c == '2' && x.ns::c.name().uri === 'u' && x.c == '0' && x.*.length() === 2", + ); + truth( + "var ns = new Namespace('u'); var x = <a xmlns:p='u' p:t='1' t='0'/>; with (x) { @ns::t = '5'; } \ + x.@ns::t == '5' && x.@ns::t.name().uri === 'u' && x.@t == '0'", + ); +} + +#[test] +fn bare_assignment_in_filter_mutates_each_matching_item() { + truth("var l = <><i id='1'/><i id='2'/></>; var r = l.(@id = 'z', true); r.length() === 2 && l[0].@id == 'z' && l[1].@id == 'z'"); + truth("var l = <><i n='1'/><i n='2'/></>; var r = l.(@n = 0); r.length() === 0 && l[0].@n == '0' && l[1].@n == '0'"); + truth("var l = <><i n='1'/><i n='2'/></>; var r = l.(@n = 'k'); r.length() === 2"); + truth("var o = <o k='1'/>; var l = <><i/><i/></>; with (o) { l.(@k += 'x', true); } o.@k == '1xx' && l[0].@k.length() === 0"); +} + +#[test] +fn bare_assignment_without_holder_throws_reference_error_before_rhs() { + assert_eq!(thrown_name("@a = 1"), "ReferenceError"); + assert_eq!(thrown_name("with ({ a: 1 }) { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with (<x/>) { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with (<x/>) { @a += 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with (<x/>) { @a++ }"), "ReferenceError"); + assert_eq!(thrown_name("var ns = new Namespace('u'); with (<x><c/></x>) { ns::c = 1 }"), "ReferenceError"); + truth("var n = 0; var e = ''; try { with (<x/>) { @a = (n++, 1); } } catch (err) { e = err.name; } e === 'ReferenceError' && n === 0"); + truth( + "var b = 'g'; var x = <x/>; var e = ''; try { with (x) { @b = 'w'; } } catch (err) { e = err.name; } \ + e === 'ReferenceError' && b === 'g' && x.@b.length() === 0 && x.*.length() === 0", + ); +} + +#[test] +fn bare_name_read_without_holder_throws_reference_error() { + assert_eq!(thrown_name("with (<x/>) { @a }"), "ReferenceError"); + assert_eq!(thrown_name("var l = <><i id='1'/><i/></>; l.(@id == '1')"), "ReferenceError"); + truth("var o = <o id='0'/>; var l = <><i id='1'/><i/></>; var r; with (o) { r = l.(@id == '1'); } r.length() === 1"); +} + +#[test] +fn bare_scope_compound_and_updates_resolve_once() { + truth("var n = 0; var x = <a t='1'/>; with (x) { @[(n++, 't')] += 1; } n === 1 && x.@t == '11'"); + truth("var n = 0; var x = <a t='1'/>; with (x) { @[(n++, 't')]++; } n === 1 && x.@t == '2'"); + truth("var x = <a t='1'/>; var r; with (x) { r = ++@t; } r === 2 && x.@t == '2'"); + truth("var x = <a t='1'/>; var r; with (x) { r = @t++; } r === 1 && x.@t == '2'"); + truth("var x = <a t='5'/>; var r; with (x) { r = --@t; } r === 4 && x.@t == '4'"); + truth("var x = <a t='5'/>; var r; with (x) { r = @t--; } r === 5 && x.@t == '4'"); +} + +#[test] +fn bare_scope_delete_removes_attribute() { + truth("var x = <a t='1' u='2'/>; var r; with (x) { r = delete @t; } r === true && x.@t.length() === 0 && x.@u == '2'"); + truth("var x = <a><c/><d/></a>; with (x) { delete *::c; } x.c.length() === 0 && x.d.length() === 1"); + assert_eq!(thrown_name("with (<a/>) { delete @t }"), "ReferenceError"); +} + +#[test] +fn bare_scope_for_targets_assign_each_iteration() { + truth("var x = <a k=''/>; var s = ''; with (x) { for (@k in { p: 1, q: 2 }) s += @k; } s === 'pq' && x.@k == 'q'"); + truth("var x = <a k=''/>; var s = ''; with (x) { for each (@k in [3, 4]) s += @k; } s === '34' && x.@k == '4'"); + assert_eq!(thrown_name("with (<a/>) { for (@k in { p: 1 }); }"), "ReferenceError"); +} + +#[test] +fn bare_scope_targets_need_e4x_option() { + for src in ["@a = 1", "ns::a = 1", "@a += 1", "++@a", "@a--", "for (@a in o);", "for each (@a in o);", "\"use strict\"; @a = 1"] { + assert!(parse(src, &Options::default()).is_err(), "{src}"); + assert!(parse(src, &e4x()).is_ok(), "{src}"); + } + truth("'use strict'; var l = <><i b='1'/></>; l.(@b = '2', true); l[0].@b == '2'"); +} + #[test] fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { truth("var x = <a><true>1</true></a>; x[true] == '1' && (true in x) && !(false in x)"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..ce1200ef6a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `<!DOCTYPE>` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 9d9cb5201736a77781abebd89067a036788532dc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:44:43 -0800 Subject: [PATCH 040/126] MCP: hidden library paths are matched before the paths a request sent A sent path whose text overlapped part of a hidden library's path could keep that path from being hidden; the hidden libraries' ids and paths are now matched first. A hidden relative path is also hidden where a reply shows the absolute path it reads as from the working folder, and a hidden library sent by that other form shows as the sent path does. --- crates/ui-egui/src/control.rs | 97 +++++++++++++++++--------- crates/ui-egui/src/control/tests.rs | 41 +++++++++-- crates/ui-egui/src/tests/automation.rs | 2 +- docs/mcp.md | 6 +- 4 files changed, 106 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c3b5b7bc02..ec2dd83ff7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -356,7 +356,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { .collect(); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - Hiding { hidden, readable: readable_ids } + Hiding { hidden, readable: readable_ids, sent: Vec::new() } } /// What a reply hides ([`hide_library_ids`]). @@ -368,6 +368,9 @@ struct Hiding { /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet<String>, + /// The paths outside the read roots that the request sent ([`sent_paths`]), each as a hidden + /// library id with the id shown. They are matched after the hidden libraries. + sent: Vec<(String, String)>, } impl Hiding { @@ -387,16 +390,26 @@ impl Hiding { self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); } - /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for - /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds + /// for the same path (written with either separator, relative or as the absolute path it reads as + /// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is. fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { let key = |id: &str| { - let id = id.replace('\\', "/"); + let path = id.strip_prefix("loaded/").unwrap_or(id); + let id = absolute_form(path).unwrap_or_else(|| path.to_string()).replace('\\', "/"); if fold { id.to_ascii_lowercase() } else { id } }; let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); - self.hidden.retain(|(id, _)| !sent.contains(&key(id))); - self.add_hidden(pairs); + for (id, shown) in &mut self.hidden { + if let Some(at) = sent.iter().position(|k| *k == key(id)) { + shown.clone_from(&pairs[at].1); + } + } + for pair in pairs { + if !self.sent.iter().any(|(id, _)| *id == pair.0) { + self.sent.push(pair); + } + } } } @@ -477,31 +490,48 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(hiding); - if pairs.is_empty() { + if pairs.iter().all(Vec::is_empty) { return; } replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } -/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and as `{:?}` -/// quotes it (each `\`, quote and control character escaped). -fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { +/// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of +/// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with +/// the id shown, and the path in it with the file name shown, the path written with either separator +/// too, and as `{:?}` quotes it (each `\`, quote and control character escaped). A relative path is +/// also replaced as the absolute path the file access check reads it as, against the working folder. +fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { + vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] +} + +/// The absolute path that `path`, a relative path (one that does not start with a separator, a drive +/// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other +/// path. +fn absolute_form(path: &str) -> Option<String> { + let relative = !path.starts_with(['/', '\\', '~']) && !matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + relative.then(|| std::path::absolute(path).ok()).flatten().map(|p| p.to_string_lossy().into_owned()) +} + +/// The text [`hidden_text`] replaces for the ids of `ids`, longest first. +fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { let quoted = |s: &str| { let q = format!("{s:?}"); q[1..q.len() - 1].to_string() }; let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in &hiding.hidden { + for (id, shown) in ids { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); - let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); - let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); - let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string())); - for (path, name) in plain.into_iter().chain(debug) { - for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { - if !pairs.iter().any(|(f, _)| *f == from) { - pairs.push((from, to)); + for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { + let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); + let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); + let plain = [path, forward, back].map(|p| (p, name.to_string())); + for (path, name) in plain.into_iter().chain(debug) { + for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } } } } @@ -510,10 +540,10 @@ fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { pairs } -/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match -/// in any case of ASCII letters. +/// What [`replace_in_value`] writes: the groups of text to replace ([`hidden_text`]), what the reply +/// hides, and whether to match in any case of ASCII letters. struct Rewrite<'a> { - pairs: Vec<(String, String)>, + pairs: Vec<Vec<(String, String)>>, hiding: &'a Hiding, fold: bool, } @@ -569,20 +599,23 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence /// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written /// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence -/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already -/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again. -/// With the rewrite's `fold`, the ids and paths as written are found first, so that two libraries whose -/// paths differ only in letter case keep their own ids, and then the ones written in another case. -/// `None` when nothing changed. +/// is found in `s` as it was given, the hidden libraries' text before the sent paths' text, each group +/// longest `from` first, and one that overlaps an occurrence already found, or a loaded library's id +/// that one began, stays: a replacement's text is not searched again. With the rewrite's `fold`, the +/// ids and paths of a group as written are found first, so that two libraries whose paths differ only +/// in letter case keep their own ids, and then the ones written in another case. `None` when nothing +/// changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; let mut found = Found::default(); - for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { - for (from, to) in &r.pairs { - if let Some((text, spans)) = &decoded { - found.claim(text, Some(spans.as_slice()), from, to, fold, r); + for group in &r.pairs { + for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { + for (from, to) in group { + if let Some((text, spans)) = &decoded { + found.claim(text, Some(spans.as_slice()), from, to, fold, r); + } + found.claim(s, None, from, to, fold, r); } - found.claim(s, None, from, to, fold, r); } } if found.cuts.is_empty() { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 6436f77bf5..7840df939b 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -28,7 +28,7 @@ fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() } } /// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. @@ -125,10 +125,41 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); - let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); - hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); - assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); + let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect(); + assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}"); + assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false); + assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}"); + // A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's + // file name is shown for both, not the library's numbered id. + let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]); + let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); + hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false); + let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")}); + hide_under_a_root("sent-absolute", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); +} + +/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path +/// from being hidden: the hidden libraries are matched first. +#[test] +fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() { + let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]); + hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false); + let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"}); + hide_under_a_root("sent-overlap", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}"); +} + +/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from +/// the working folder. +#[test] +fn a_reply_hides_a_hidden_relative_path_written_as_absolute() { + let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); + let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); + let mut reply = json!({"error": format!("can't read {absolute}")}); + hide_under_a_root("relative-absolute", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read icon.svg", "{reply}"); } /// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index b1a1fbce75..3b93e2396a 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -884,7 +884,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index 909dc91a40..61bce08b35 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -161,8 +161,10 @@ the suite) keep the agent to the folders you give it: another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, - `destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a - longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`, + `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are + matched before the paths the request sent, so a sent path written over part of a hidden library's path does not + keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` + keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` From d151f3f5d00294bea9eec39dbd28793761cae4e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:26:54 -0800 Subject: [PATCH 041/126] JavaScript: source that eval and Function compile takes fuel before it is parsed Direct and indirect eval, Function and host evaluators parsed and compiled their source before taking any fuel or checking the heap, so a script with a small fuel limit could make the interpreter parse megabytes of source in a handful of instructions. Interp::prepare_source now takes one unit of fuel for each UTF-16 code unit and checks that the source text fits in the heap before the source is flattened, parsed or compiled. A host evaluator's string takes the fuel for a third of its bytes before it is encoded, and the rest after. Limits::fuel and the maxFuel row in docs/mcp.md say so. --- crates/js/src/builtins.rs | 2 ++ crates/js/src/host.rs | 18 +++++++++++++++--- crates/js/src/host_tests.rs | 21 +++++++++++++++++++++ crates/js/src/interp.rs | 20 +++++++++++++++++++- crates/js/src/interp_tests.rs | 18 ++++++++++++++++-- crates/js/src/realm.rs | 3 ++- docs/mcp.md | 2 +- 7 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index f7c21a8d8e..301b1235a4 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -466,6 +466,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe .chain([parts.len().saturating_sub(1), HEAD.len() + MID.len() + TAIL.len(), body.len()]) .fold(0usize, usize::saturating_add); interp.check_source_units(total).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.prepare_source(total)?; let mut units = Vec::with_capacity(total); units.extend(HEAD.encode_utf16()); for (i, p) in parts.iter().enumerate() { @@ -554,6 +555,7 @@ fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Valu let x = arg(args, 0); let Value::String(s) = &x else { return Ok(x) }; interp.check_source_units(s.len()).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.prepare_source(s.len())?; let units = s.flat(); let code = interp.compile_source(&units, false, CodeKind::Eval).map_err(|e| interp.compile_error_to_abrupt(e))?; interp.run_global(code, true) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 883a288991..e66b109b03 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -397,11 +397,23 @@ impl Interp { let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); self.evaluated = self.evaluated.saturating_add(1); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); - let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval)); + // UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the + // bytes is taken before the source is encoded, and the rest after. + let floor = source.len().div_ceil(3); + let code = self.prepare_source(floor).map(|()| { + let units = self.encode_source(&source); + let rest = match &units { + Ok(units) => self.prepare_source(units.len().saturating_sub(floor)), + Err(_) => Ok(()), + }; + rest.map(|()| units.and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval))) + }); + let code = code.and_then(|c| c); self.source_tag = tag; let r = match code { - Ok(code) => self.run_global(code, true), - Err(e) => Err(self.compile_error_to_abrupt(e)), + Ok(Ok(code)) => self.run_global(code, true), + Ok(Err(e)) => Err(self.compile_error_to_abrupt(e)), + Err(limit) => Err(limit), }; end(); r diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 6fc6c4e04f..fe7ce894f9 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -540,6 +540,27 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn an_evaluators_source_takes_fuel_before_it_is_parsed() { + // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. + let ends = Rc::new(Cell::new(0)); + let ended = ends.clone(); + let source: HostFunction = Rc::new(|_| Ok(Data::String(format!("{}7", " ".repeat(1 << 17))))); + let item = HostItem::Evaluator { source, end: Rc::new(move || ended.set(ended.get() + 1)), strict_writes: Rc::new(|| None) }; + let mut r = realm(10_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + let e = r.run("host.evaluate()").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 1, "the evaluator's end runs once"); + // This fuel covers a third of the source's bytes but not all of its code units. + r.set_limits(Limits { fuel: 60_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 2); + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()"), Ok(JsValue::Number(7.0))); + assert_eq!(ends.get(), 3); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..a67dd586b2 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -9,7 +9,8 @@ //! Before each instruction the interpreter takes one unit of fuel, checks the interrupt flag and, //! when the heap has grown past its threshold, collects garbage. Running out of fuel, the heap //! limit, the frame limit, the re-entry limit and the interrupt flag each end the run with an -//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. +//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. Source +//! compiled during a run takes a unit of fuel for each code unit before it is parsed. //! //! Collection runs only between instructions. An operation that may run JavaScript in the middle //! keeps its operands on the operand stack until it is done, so everything it holds is reachable @@ -617,6 +618,22 @@ impl Interp { Ok(()) } + /// Prepares for parsing `units` code units of source at run time: takes a unit of fuel for + /// each code unit and checks that the source text fits in the heap, before any of it is + /// flattened, parsed or compiled. Running out of fuel uses up what was left. + pub(crate) fn prepare_source(&mut self, units: usize) -> JsResult<()> { + let fuel = u64::try_from(units).unwrap_or(u64::MAX); + if fuel > self.fuel { + self.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.fuel -= fuel; + if self.interrupt.load(Ordering::Relaxed) { + return Err(Abrupt::Limit(LimitKind::Interrupted)); + } + self.reserve_heap(string_bytes(units)) + } + /// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or /// `return` left: those whose stack slot no longer holds their exception. fn drop_left_finally_marks(&mut self) { @@ -2397,6 +2414,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; + self.prepare_source(s.len())?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 28157d6eea..93c487b378 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -500,6 +500,19 @@ fn declarations_take_fuel() { assert_eq!(limit(&mut realm, &format!("Function('var {vars};')();")), LimitKind::Fuel); } +#[test] +fn source_compiled_during_a_run_takes_fuel_before_it_is_parsed() { + // Doubling a string 17 times makes 131,072 spaces in about a hundred instructions, so only + // the compiled source can use up the fuel. + let build = "var s = ' '; for (var i = 0; i < 17; i++) s += s;"; + let mut realm = small(Limits { fuel: 10_000, ..Limits::default() }); + for call in ["eval(s)", "(0, eval)(s)", "Function(s)", "new Function('a', s)"] { + assert_eq!(limit(&mut realm, &format!("{build} {call};")), LimitKind::Fuel, "{call}"); + } + let mut realm = small(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(realm.run(&format!("{build} eval(s + '1') + (0, eval)(s + '2') + Function(s + 'return 3')()")), Ok(JsValue::Number(6.0))); +} + #[test] fn a_lower_heap_limit_applies_to_what_is_already_live() { // A heap already past the new limit ends the next run. @@ -823,13 +836,14 @@ fn long_property_names_count_toward_the_heap_after_their_strings_are_collected() // Each name is a 16,385-unit buffer that only the object's key or the function's binding // holds once the string that made it is garbage, so about 32 of them fill the heap well before // the fuel runs out. In the second script the property lookup flattens a long string each - // time, so collections run. + // time, so collections run. Each eval there also takes fuel for its source, so that run has + // more fuel. let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); let src = "var s = 'x'; while (s.length < 10000) s += s; var o = {}; for (var i = 0; ; i++) o[s + i] = 1;"; assert_eq!(limit(&mut realm, src), LimitKind::Heap); let src = "var s = 'x'; while (s.length < 10000) s += s; \ (function () { for (var i = 0; ; i++) { eval('var ' + s + i + ' = 1'); ({})[s + i]; } })();"; - let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 5_000_000, ..Limits::default() }); assert_eq!(limit(&mut realm, src), LimitKind::Heap); } diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f274c42ead..b17d6a2770 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -14,7 +14,8 @@ use crate::{Options, SyntaxError}; /// cannot catch it, and their `finally` blocks do not run. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Limits { - /// Instructions a run may execute. + /// Instructions a run may execute. Source that `eval`, `Function` or a host evaluator + /// compiles during the run also takes one unit for each UTF-16 code unit, before it is parsed. pub fuel: u64, /// Estimated bytes the heap (objects, environments and the strings they hold) may reach /// after a collection. A string longer than a quarter of this many code units cannot be diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..5065a0e623 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1826,7 +1826,7 @@ VectorCraft sets these limits itself: | Included and evaluated files | 64 in all, 16 levels deep, 1,024 paths looked up | | Heap | 256 MiB (128 MiB on the web) | | Host calls | 2,000,000 | -| `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | +| `maxFuel`, instructions in all, plus one for each UTF-16 code unit of source that `eval`, `Function` or an evaluated file compiles during the run | 1 to 100,000,000,000; 1,000,000,000 by default | | Instructions without a pause | 200,000,000 | | Nesting depth | 64 levels | | A value crossing to or from a command | 16 MiB and 1,048,576 entries | From 7c28138ce3ca14edf62990880157eb609e8802d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:57:39 -0800 Subject: [PATCH 042/126] Commands: pointer packets are read whole before any event runs A headless or control channel pointer packet now checks every event's kind, coordinates and mods before it sends any of them, so a malformed later event no longer leaves the earlier ones applied. A mods value that is not an object, such as an array, is refused. Rotate View takes null mods as omitted. The MCP docs name the params that refuse null. --- crates/mcp/src/headless.rs | 37 +++++++++++++------------- crates/mcp/src/tests.rs | 5 ++++ crates/ui-egui/src/control.rs | 29 +++++++++++--------- crates/ui-egui/src/control/tests.rs | 24 +++++++++++++++++ crates/ui-egui/src/tests/rotateview.rs | 3 ++- docs/mcp.md | 4 ++- 6 files changed, 70 insertions(+), 32 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0377cd5ae1..cf150623ef 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -71,7 +71,7 @@ fn opt_bool(p: &Value, k: &str) -> Result<Option<bool>, String> { /// A pointer packet's `mods`: none when omitted or null, else an object of booleans. fn packet_mods(p: &Value) -> Result<Option<Mods>, String> { - opt(p, "mods", |m| serde_json::from_value(m.clone()).ok(), "an object of true or false values") + opt(p, "mods", |m| m.as_object().and_then(|_| serde_json::from_value(m.clone()).ok()), "an object of true or false values") } pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> { @@ -231,25 +231,26 @@ impl Headless { let base = packet_mods(p)?.unwrap_or_default(); let default = PointerEvent::json_backquote(p, false)?; let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::<Result<Vec<_>, _>>()?; - let mods = events.iter().map(|e| Ok(packet_mods(e)?.unwrap_or(base))).collect::<Result<Vec<_>, String>>()?; + // Every event is read before any is sent, so a malformed packet sends none of them. + let read = events + .iter() + .zip(held) + .map(|(e, backquote)| { + let mods = packet_mods(e)?.unwrap_or(base); + let kind = s(e, "kind").unwrap_or(""); + let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + if s(e, "space") == Some("screen") { + return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); + } + let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; + let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; + Ok((e, kind, Point::new(x, y), mods, backquote)) + }) + .collect::<Result<Vec<_>, String>>()?; self.ensure_document(); let mut requests = vec![]; - for ((e, backquote), mods) in events.iter().zip(held).zip(mods) { - let kind = s(e, "kind").unwrap_or(""); - let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; - if s(e, "space") == Some("screen") { - return Err("screen-space pointer events need the desktop app; use document coordinates".into()); - } - let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; - let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; - let ev = PointerEvent { - kind, - pos: Point::new(x, y), - mods, - backquote, - pressure: PointerEvent::json_pressure(e), - pen: PointerEvent::json_pen(e), - }; + for (e, kind, pos, mods, backquote) in read { + let ev = PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen: PointerEvent::json_pen(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; // With no frames to show the highlight or apply an edit in later, each event waits for diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index dbe429a281..86cc6228a0 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1461,6 +1461,7 @@ fn headless_methods_refuse_params_of_the_wrong_type() { h.call("ui.tool.select", json!({"tool": "rectangle"})).unwrap(); let before = h.call("document.json", json!({})).unwrap(); let drag = |mods: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60, "mods": mods}, {"kind": "up", "x": 60, "y": 60}]}); + let after_rect = |last: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60}, {"kind": "up", "x": 60, "y": 60}, last]}); for (method, params, key) in [ ("ui.render", json!({"artboard": "0"}), "artboard"), ("ui.render", json!({"scale": "2"}), "scale"), @@ -1469,6 +1470,10 @@ fn headless_methods_refuse_params_of_the_wrong_type() { ("ui.key", json!({"key": "Delete", "shift": "true"}), "shift"), ("ui.pointer", drag(json!({"shift": "yes"})), "mods"), ("ui.pointer", json!({"mods": 1, "events": []}), "mods"), + ("ui.pointer", drag(json!([true])), "mods"), + // A whole rectangle comes before the malformed event, and none of it is drawn. + ("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"), + ("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"), ] { let e = h.call(method, params.clone()).unwrap_err(); assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 77e931bdc1..34eaa7944e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -509,6 +509,7 @@ fn coord(p: &Value, key: &str, default: f32) -> Result<f32, String> { fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { match value { None | Some(Value::Null) => Ok(None), + Some(m) if !m.is_object() => Err("`mods` must be an object of true or false values".to_string()), Some(m) => serde_json::from_value(m.clone()).map(Some).map_err(|e| format!("`mods` must be an object of true or false values: {e}")), } } @@ -920,7 +921,7 @@ fn rotate_view_pointer( /// A Rotate View packet accepts only the documented boolean modifier fields. fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> { - let Some(value) = value else { return Ok(()) }; + let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(()) }; let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) }; if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) { return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into()); @@ -975,8 +976,16 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con events .iter() .map(|e| { + let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + other => return Err(format!("unknown pointer kind `{other}`")), + }; let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); - Ok((at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) + Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) }) .collect::<Result<Vec<_>, String>>() }); @@ -986,6 +995,10 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con }; let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); + if !rotating && xf.is_none() && events.iter().any(screen) { + return err("no canvas yet"); + } let from = app.session.input_count(); let held = PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>()); @@ -993,16 +1006,8 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Ok(held) => held, Err(e) => return err(e), }; - for ((e, backquote), (x, y, mods)) in events.iter().zip(held).zip(read) { - let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { - "down" => PointerKind::Down, - "drag" => PointerKind::Drag, - "up" => PointerKind::Up, - "move" => PointerKind::Move, - "doubleclick" | "dblclick" => PointerKind::DoubleClick, - other => return err(format!("unknown pointer kind `{other}`")), - }; - let screen = e.get("space").and_then(Value::as_str) == Some("screen"); + for ((e, backquote), (kind, x, y, mods)) in events.iter().zip(held).zip(read) { + let screen = screen(e); if rotating { if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { return err(e); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 2cdf13a4d7..021365d817 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -349,6 +349,7 @@ fn control_input_refuses_params_of_the_wrong_type() { ("ui.set", json!({"grid": true, "controlBar": 0}), "controlBar"), ("ui.pointer", json!({"events": [{"kind": "move", "x": "5", "y": 0}]}), "x"), ("ui.pointer", move_to(json!({"shift": "yes"})), "mods"), + ("ui.pointer", move_to(json!([true])), "mods"), ("ui.pointer", json!({"mods": "shift", "events": [{"kind": "move", "x": 1, "y": 1}]}), "mods"), ] { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -456,6 +457,29 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { } } +/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any +/// of the packet's events runs. +#[test] +fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(input_request(&mut app, "ui.tool.select", json!({"tool": "selection"}))["ok"], true); + let before = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let moves = [json!({"kind": "down", "x": 125, "y": 125}), json!({"kind": "drag", "x": 145, "y": 125}), json!({"kind": "up", "x": 145, "y": 125})]; + for (last, error) in [ + (json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"), + ] { + let events = moves.iter().cloned().chain([last]).collect::<Vec<_>>(); + let r = input_request(&mut app, "ui.pointer", json!({"events": events})); + assert_eq!(r["ok"], false, "{error}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains(error)), "{r}"); + assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(), &before, "{error}: the drag does not run"); + } +} + /// `ui.pointer` replies with the key object: a click on one of two selected objects makes it /// the key, and a click on the key again lets it go. #[test] diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index dbdb0283fd..715610f08e 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -117,7 +117,8 @@ fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_request send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]})); assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes"); } - send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]})); + // Null `mods` keep the default, as an omitted value does. + send(&mut app, &ctx, "ui.pointer", json!({"mods": null, "events": [{"kind": "up", "x": x, "y": y, "mods": null}]})); assert_eq!(app.view().unwrap().rotation, 45.0); } diff --git a/docs/mcp.md b/docs/mcp.md index 7ced9eb156..078488e9d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1271,7 +1271,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (` `ui.symbolOptions` (`edit`), `ui.batchDialog` (`set`, `action`) and `file.exportSelection` (`multiple`). So do the control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`, `ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot -calls. Omitting a param or giving null keeps its default: +calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s +`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one +malformed event sends none of its events: ```json {"id": "view.setZoom", "params": {"zoom": "200"}} From d5044de8b15489af65ee63bc770e040b3011a524 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:59:09 -0800 Subject: [PATCH 043/126] MCP: another platform's absolute path is outside the read roots everywhere Outside Windows, a path that starts with a drive (`C:`) or a `\` is read as a name in the working folder, so with the working folder inside a read root it passed the read check. ui.inspect then kept it in recorded action steps and replies did not hide it when a request sent it. The check now counts such a path as outside the read roots wherever the control channel and the library commands check a path. --- crates/engine/src/cmd/swatchlib.rs | 15 ++++----------- crates/engine/src/file_access.rs | 7 +++++++ crates/ui-egui/src/control.rs | 5 +++-- crates/ui-egui/src/control/tests.rs | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 7934de2c0f..6f007c182d 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -472,17 +472,10 @@ fn file_name(path: &str) -> String { } /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without -/// roots in force. +/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is +/// never readable under a read root. fn readable(path: &str) -> bool { - crate::file_access::check_read(path).is_ok() -} - -/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one -/// that starts with a drive (`C:`) or a `\`? The file access check would read it as a name in the -/// working folder. -fn written_elsewhere(path: &str) -> bool { - let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - !cfg!(windows) && foreign && crate::file_access::current().is_some() + !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each @@ -514,7 +507,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin (name, path) }); match name { - Some((name, path)) if name != path && (written_elsewhere(path) || !readable(path)) => hidden.push((id, name)), + Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { taken.insert(id); } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 9d05b17272..6ca724d810 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder. +pub fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && current().is_some() +} + /// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, /// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, /// `destinationFolder`)? diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ec2dd83ff7..4ee30c980a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -824,9 +824,10 @@ const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; /// May the request being served read `path`? Under an automation read root (a control client's /// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent -/// file or a listed script outside it. +/// file or a listed script outside it. Another platform's absolute path +/// ([`vectorcraft_engine::file_access::written_elsewhere`]) is never readable under a read root. fn readable(path: &str) -> bool { - vectorcraft_engine::file_access::check_read(path).is_ok() + !vectorcraft_engine::file_access::written_elsewhere(path) && vectorcraft_engine::file_access::check_read(path).is_ok() } /// Is `s` a path the request may not read, written as an absolute path (either separator, with or diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 7840df939b..52b5d9e957 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -263,6 +263,21 @@ fn a_library_saved_with_another_platforms_path_is_hidden() { assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); } +/// Another platform's absolute path is outside the read root, also when the working folder is inside +/// it: a request that sends one sees it by its file name. +#[test] +fn another_platforms_absolute_path_is_unreadable() { + let here = std::env::current_dir().unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); + let path = r"C:\Users\someone\private.svg"; + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(!super::readable(path) && super::unreadable_absolute(path)); + super::sent_paths(&json!({"list": [path]}), &mut sent); + }); + assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); +} + /// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a /// longer path. #[test] From 8bd437687a556bf9d76bfe6aa39a92c6b605dcb9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:01:25 -0800 Subject: [PATCH 044/126] Scripts: a canceled request stops a sleeping or waiting run at its next slice script_pump read vectorcraft_doc::cancel only after the guard that keeps a run waiting, so a run asleep in $.sleep went on waiting until its wake time (up to 1,000 ms), and a run waiting at a dialog or for approval to grow waited until someone answered. The check now runs before that guard, after the dialog-failure check, so a canceled request ends the run at the next slice and every document goes back. --- crates/engine/src/script/mod.rs | 7 ++--- crates/engine/src/tests/script_dialogs.rs | 20 +++++++++++++++ crates/engine/src/tests/script_dollar.rs | 31 +++++++++++++++++++++++ crates/engine/src/tests/script_run.rs | 24 ++++++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + 6 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index a8f0c18336..c3b3084e21 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -1787,6 +1787,10 @@ impl Session { if let Some(e) = job.host.dialogs.take_failure() { return Some(self.script_failed(job, &e)); } + // A canceled request ends a sleep or a wait at this pump. + if vectorcraft_doc::cancel::check().is_err() { + self.script_stop.set(); + } if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); @@ -1798,9 +1802,6 @@ impl Session { let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; return Some(self.script_failed(job, &e)); } - if vectorcraft_doc::cancel::check().is_err() { - self.script_stop.set(); - } job.host.calls_this_slice.set(0); let outcome = run_slice(self, &mut job); match outcome { diff --git a/crates/engine/src/tests/script_dialogs.rs b/crates/engine/src/tests/script_dialogs.rs index 7572ca52f0..f955a6fdb3 100644 --- a/crates/engine/src/tests/script_dialogs.rs +++ b/crates/engine/src/tests/script_dialogs.rs @@ -217,6 +217,26 @@ fn dialog_loop_stops_or_exhausts() { assert_eq!(art(&s), before); } +#[test] +fn a_run_waiting_at_a_dialog_stops_at_the_next_slice_once_its_work_is_canceled() { + let mut s = one_doc(); + let before = art(&s); + let token = crate::cancel::CancelToken::new(); + s.script_frames = true; + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); alert('x'); vc.log('answered');"; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true); + assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none()); + assert!(s.script_dialog().is_some()); + token.cancel(); + let ended = crate::cancel::scope(&token, None, || s.script_pump()); + assert!(ended.expect("the run goes on waiting for an answer").is_err()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{report}"); + assert_eq!(report["dialogs"][0]["answer"], "stop"); + assert_eq!(s.script_dialog(), None); + assert_eq!(art(&s), before); +} + #[test] fn dialog_text_limits_and_controls() { let mut s = one_doc(); diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index d5b9624500..57ff9b15cd 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -5,6 +5,7 @@ use std::time::Duration; use serde_json::{Value, json}; +use crate::cancel::{self, CancelToken}; use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK}; use crate::*; @@ -204,6 +205,36 @@ fn a_sleeping_run_stops_when_asked() { TEST_ELAPSED.set(None); } +#[test] +fn a_sleeping_run_stops_at_the_next_slice_once_its_work_is_canceled() { + for (asleep_until, when) in + [(None, "before its first slice"), (Some(Duration::ZERO), "while it sleeps"), (Some(Duration::from_secs(1)), "at its wake time")] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = (format!("{:?}", s.documents()[0].doc.layers), s.documents()[0].history.undo.len()); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 5, height: 5}); $.sleep(1000); vc.log('woke'); \ + vc.run('shape.rectangle', {x: 9, y: 0, width: 5, height: 5});"; + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + let token = CancelToken::new(); + if let Some(at) = asleep_until { + assert!(cancel::scope(&token, None, || s.script_pump()).is_none(), "{when}"); + assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1)), "{when}"); + TEST_ELAPSED.set(Some(at)); + } + token.cancel(); + let ended = cancel::scope(&token, None, || s.script_pump()); + TEST_ELAPSED.set(None); + assert!(ended.unwrap_or_else(|| panic!("{when}: the run goes on waiting")).is_err(), "{when}"); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{when}: {report}"); + assert!(!s.script_running(), "{when}"); + assert_eq!((format!("{:?}", s.documents()[0].doc.layers), s.documents()[0].history.undo.len()), before, "{when}"); + } +} + #[test] fn sleep_takes_milliseconds_from_0_to_1000() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 3359f272f9..417798a862 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -845,6 +845,30 @@ fn a_person_can_let_a_growing_run_go_on_or_stop_it() { assert_eq!(state(&s), before); } +#[test] +fn a_run_waiting_to_grow_stops_at_the_next_slice_once_its_work_is_canceled() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let mut s = two_docs(); + let before = state(&s); + s.script_frames = true; + run(&mut s, "for (var i = 0; i < 25; i++) vc.run('shape.rectangle', {x:i,y:1,width:1,height:1});").unwrap(); + let token = crate::cancel::CancelToken::new(); + for _ in 0..1_000 { + if s.script_waiting() { + break; + } + assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none()); + } + assert!(s.script_waiting()); + token.cancel(); + let ended = crate::cancel::scope(&token, None, || s.script_pump()); + TEST_GROWTH.with(|g| g.set(None)); + assert!(ended.expect("the run goes on waiting for Continue").is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "stopped"); + assert!(!s.script_running()); + assert_eq!(state(&s), before); +} + #[test] fn a_file_saved_before_a_failure_stays_written_and_is_named() { let path = std::env::temp_dir().join(format!("vc-script-save-{}.svg", std::process::id())); diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..f331b0bb68 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1742,7 +1742,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: | `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | | `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | | `screens` | an empty array | -| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait, and so does `notifications/cancelled` for a run its `tools/call` waits for, without waiting for the time to pass; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | | `strict` | `false` | Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..6bd2ee6c45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -740,6 +740,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. + - Scripts: a canceled request stops a run that sleeps in `$.sleep`, waits at a dialog or waits for approval to grow at the run's next slice, before its wake time or an answer, and every document goes back to its state before the run. VectorCraft's own rule. `crates/engine/src/tests/script_dollar.rs`, `crates/engine/src/tests/script_dialogs.rs`, `crates/engine/src/tests/script_run.rs`. - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. From 3fd653a25af6ac9245ada53db6a41775bdb9426b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:35:16 -0800 Subject: [PATCH 045/126] EPS: Save As shows the saved artboard as the range when Use Artboards was on A save that set useArtboards: true and named an artboard with artboard or artboards, without range, wrote that artboard, but file.formatOptions reported the default range. The Save As dialog then opened on All, and OK saved every artboard. file.formatOptions now reports those artboards as the range unless Use Artboards was saved off. --- crates/engine/src/cmd/fileio/save.rs | 6 +++--- crates/engine/src/tests/save.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index da710553bf..301a7c79fe 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1094,11 +1094,11 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> { } } } - // An EPS save that named artboards without Use Artboards had it on (the encoder reads them - // so): it shows on, with those artboards as the range. + // An EPS save that named artboards without Use Artboards, or with it on, had it on (the + // encoder reads them so): it shows on, with those artboards as the range. if f.id == "eps" && let Some(m) = &saved - && m.get("useArtboards").is_none_or(Value::is_null) + && m.get("useArtboards").and_then(Value::as_bool) != Some(false) { let range = m.get("range").and_then(Value::as_str).filter(|r| !r.trim().is_empty()); let boards = m diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 2b31bddb3b..22a0239fb2 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -510,6 +510,21 @@ fn eps_save_keeps_all_editing_artboards_in_the_file_of_one_page() { assert!(s.execute("document.save", &json!({"format": "eps", "options": {"selectedOnly": true}})).is_err(), "selected-only is Export's job"); } +#[test] +fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() { + let d = dir("eps-named-artboard"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "artboard": 1, "previewFormat": "none", "thumbnails": false}); + s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let o = s.execute("file.formatOptions", &json!({})).unwrap(); + assert_eq!(o["options"]["useArtboards"]["value"], true); + assert_eq!(o["options"]["range"]["value"], "2", "the saved artboard, not All"); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { let d = dir("eps-artboards"); From e2eb364f836f85d672fb89172483a6a4b97c08f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:35:25 -0800 Subject: [PATCH 046/126] Scripts: operators, JSON.stringify and join count a flattened string against the heap limit Flattening a concatenated string copies its units into a new buffer. The string built-ins reserve that copy with flat_charged, but several other paths flattened without it, so a script could build copies the heap limit never saw: - the relational operators (`<`, `>`, `<=`, `>=`) on two strings, `==` between a string and a number, unary `+` and every other ToNumber of a string, and reading a character by index (`s[0]`) - a property name made from an object whose toString returns a long string, and a long string used as a property name, which now reserves its copy before it is made instead of after - direct `eval` of a concatenated source - `JSON.stringify` of a string value and its `space` argument - `Array.prototype.join`'s separator and each string element it writes Property names and eval source reserve the copy's heap bytes without taking fuel. --- crates/js/src/builtins/array.rs | 6 +++-- crates/js/src/builtins/json.rs | 8 +++++-- crates/js/src/interp.rs | 40 +++++++++++++++++++++------------ crates/js/src/text_tests.rs | 17 ++++++++++++++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 2f81bf7666..263656437c 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -13,6 +13,7 @@ use super::{ }; use crate::heap::{Class, NativeKind, ObjId}; use crate::interp::{Abrupt, Interp, JsResult, strict_eq}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, to_uint32}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -278,7 +279,7 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int if len == 0 { return Ok(Value::str("")); } - let flat = sep.flat(); + let flat = flat_charged(interp, &sep)?; let sep = &flat[..]; // A result that cannot fit is refused before any element is read. let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); @@ -301,7 +302,8 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( let Some(s) = item(interp, v)? else { continue }; out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; - out.push_units(interp, &s.flat())?; + let units = flat_charged(interp, &s)?; + out.push_units(interp, &units)?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish(interp)) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 4375303fe0..991495ba9d 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -473,7 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Se let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; s.gap = vec![u16::from(b' '); n]; } - Value::String(t) => s.gap = t.flat().iter().take(10).copied().collect(), + Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), _ => {} } Ok(s) @@ -632,7 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), Value::Number(_) => out.push_str(interp, "null"), - Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::String(s) => { + let units = flat_charged(interp, s)?; + write_quoted(interp, out, &units) + } Value::Undefined | Value::Object(_) => Ok(()), } } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..afca1a1024 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -26,6 +26,7 @@ use crate::heap::{ ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes, }; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; use crate::{LimitKind, Limits, Options}; @@ -1290,7 +1291,7 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + let unit = flat_charged(self, s)?.get(i as usize).copied().unwrap_or(0); return Ok(Value::String(Str::from_units(vec![unit]))); } } @@ -2397,7 +2398,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; - let units = s.flat(); + let units = self.flat_reserved(s)?; let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let frame = self.frame()?; @@ -2562,7 +2563,7 @@ impl Interp { Value::Null => 0.0, Value::Bool(b) => f64::from(u8::from(*b)), Value::Number(n) => *n, - Value::String(s) => string_to_number(&s.flat()), + Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; return self.to_number(&p); @@ -2596,19 +2597,27 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_key(&mut self, v: &Value) -> JsResult<Key> { match v { - Value::String(s) => { - let rope = s.is_rope(); - let flat = s.flat(); - if rope { - self.heap.bytes += string_bytes(flat.len()); - } - Ok(flat) - } + Value::String(s) => self.flat_reserved(s), Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), - _ => Ok(self.to_str(v)?.flat()), + _ => { + let s = self.to_str(v)?; + self.flat_reserved(&s) + } } } + /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that + /// copy's bytes are reserved and added to the heap first, without taking fuel. Everything + /// the caller still needs must be reachable from the roots. + fn flat_reserved(&mut self, s: &Str) -> JsResult<Key> { + if s.is_rope() { + let bytes = string_bytes(s.len()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + } + Ok(s.flat()) + } + /// ToObject (section 9.9). #[allow(clippy::wrong_self_convention)] pub(crate) fn to_object(&mut self, v: &Value) -> JsResult<ObjId> { @@ -2731,7 +2740,8 @@ impl Interp { (self.to_primitive(x, Hint::Number)?, py) }; if let (Value::String(a), Value::String(b)) = (&px, &py) { - return Ok(Some(*a.flat() < *b.flat())); + let (a, b) = (flat_charged(self, a)?, flat_charged(self, b)?); + return Ok(Some(*a < *b)); } let nx = self.to_number(&px)?; let ny = self.to_number(&py)?; @@ -2756,7 +2766,9 @@ impl Interp { | (Value::String(_), Value::String(_)) | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), - (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())), + (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { + return Ok(*n == string_to_number(&flat_charged(self, s)?)); + } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), (Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?, diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index c873bdaf59..3134e318df 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -309,6 +309,23 @@ fn flattening_a_rope_counts_against_the_heap() { } } +#[test] +fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { + // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + let not_stopped: Vec<_> = + ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; From b172e3672dd1153050c756dec44f8db896eddfbb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:35:23 -0800 Subject: [PATCH 047/126] JavaScript: for each evaluates its target before reading the value A for each loop read the property's value before evaluating its target, so a target such as (o.p = 2, t).v, t[(o.p = 3, 'w')] or a bare @[(o.p = 2, 'k')] stored the value from before the target ran. The target's object and name are now evaluated first and the value is read after them (ECMA-357 section 12.3). --- crates/js/src/bytecode.rs | 7 +++--- crates/js/src/compiler.rs | 51 +++++++++++++++++++++++++++++++++++--- crates/js/src/interp.rs | 2 +- crates/js/src/xml.rs | 8 +++--- crates/js/src/xml_tests.rs | 8 ++++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 72bd9ef90e..6992fd5606 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -173,8 +173,9 @@ pub(crate) enum Op { XmlFilterTest, /// `state` → the XMLList of the items kept. XmlFilterEnd, - /// `iterator name` → `iterator value`: the value under the name a `for each` got. - ForEachValue, + /// `iterator name x₁…xₙ` → `iterator name x₁…xₙ value`: the value under the name a `for each` + /// got, read after the `n` values its target pushed. + ForEachValue(u32), /// `value` → nothing: sets the default XML namespace of the running code. SetDefaultNamespace, } @@ -312,7 +313,7 @@ fn effect(op: Op, finally_tables: &[Vec<u32>]) -> Result<Effect, String> { Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), Op::XmlScopeRef => e(1, 2), - Op::ForEachValue => e(2, 2), + Op::ForEachValue(n) => e(n + 2, n + 3), Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index effb475f92..3023e3b127 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -497,16 +497,21 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); - if each { - self.emit(Op::ForEachValue); - } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); + if each { + self.emit(Op::ForEachValue(0)); + } self.emit(Op::SetVar(n)); } + ForInTarget::Expr(e) if each => self.store_value_to(*e)?, ForInTarget::Expr(e) => self.store_key_to(*e)?, } + if each { + // The value; the name under it goes next. + self.emit(Op::Pop); + } self.emit(Op::Pop); self.loop_body(body, labels, brk, head)?; self.jump(Op::Jump, head); @@ -552,6 +557,46 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Assigns the value under the name on top of the stack to a `for each` target expression, + /// leaving the name and then the value on top. The target is evaluated before the value is + /// read (ECMA-357 section 12.3). + fn store_value_to(&mut self, target: ExprId) -> CResult<()> { + let target = self.ast.unparenthesized(target); + self.at(self.ast.expr_span(target).start); + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::ForEachValue(0)); + self.emit(Op::SetVar(n)); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + self.emit(Op::ForEachValue(1)); + let n = self.name_key(key_of(&property.name)); + self.emit(Op::SetMember(n)); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::ToKey); + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + _ => { + self.expr(target)?; + self.emit(Op::Pop); + self.emit(Op::ForEachValue(0)); + self.throw_invalid_target(); + } + } + Ok(()) + } + fn throw_invalid_target(&mut self) { let s = self.string(&"invalid assignment target".encode_utf16().collect::<Vec<u16>>()); let depth = self.depth; diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b8213fbaf3..54fd3431a2 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -927,7 +927,7 @@ impl Interp { Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), Op::XmlFilterEnd => self.op_xml_filter_end(), - Op::ForEachValue => self.op_for_each_value(), + Op::ForEachValue(n) => self.op_for_each_value(n as usize), Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9104ba83f7..8ef2e3c4c1 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -2895,9 +2895,9 @@ impl Interp { self.replace(1, Value::Object(out)) } - pub(crate) fn op_for_each_value(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let Value::Object(iter) = self.peek(1)? else { return Err(Abrupt::Internal("for-in iterator is not an object".into())) }; + pub(crate) fn op_for_each_value(&mut self, above: usize) -> JsResult<()> { + let name = self.peek(above)?; + let Value::Object(iter) = self.peek(above + 1)? else { return Err(Abrupt::Internal("for-in iterator is not an object".into())) }; let object = match self.heap.object(iter).map(|o| &o.class) { Some(Class::ForIn(state)) => state.object, _ => return Err(Abrupt::Internal("not a for-in iterator".into())), @@ -2909,7 +2909,7 @@ impl Interp { } None => Value::Undefined, }; - self.replace(1, v) + self.push(v) } /// The URI of a Namespace value or of its string form. diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 96ac9c3aed..82666cd41f 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -716,6 +716,14 @@ fn bare_scope_for_targets_assign_each_iteration() { assert_eq!(thrown_name("with (<a/>) { for (@k in { p: 1 }); }"), "ReferenceError"); } +#[test] +fn for_each_evaluates_its_target_before_reading_the_value() { + truth("var o = { p: 1 }; var t = {}; for each ((o.p = 2, t).v in o); t.v === 2"); + truth("var o = { p: 1 }; var t = {}; for each (t[(o.p = 3, 'w')] in o); t.w === 3"); + truth("var x = <a k=''/>; var o = { p: 1 }; with (x) { for each (@[(o.p = 2, 'k')] in o); } x.@k == '2'"); + truth("var o = { p: 1, q: 2 }; var s = ''; for each (var v in o) s += v; var t; for each (t in o) s += t; s === '1212' && t === 2"); +} + #[test] fn bare_scope_targets_need_e4x_option() { for src in ["@a = 1", "ns::a = 1", "@a += 1", "++@a", "@a--", "for (@a in o);", "for each (@a in o);", "\"use strict\"; @a = 1"] { From cd262fa6e805f5eab43ec8a955524c6a99ce7611 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:42:34 -0800 Subject: [PATCH 048/126] MCP: save, export and package replies leave out paths outside a confined client's read roots A client confined with --automation-read-root could learn paths it was not given and may not read from the replies of several commands. Each one now names such a path only when no read roots are in force or the path is inside them: - document.save and file.save, file.saveAs, file.saveCopy and file.saveAsTemplate (app.save and engine.execute) without a path: the document's own file, the files of separate artboards and linked files are left out or named by file name, and the folder a save suggests is left out. - document.exportForScreens with the folder of an exportPreset: the ZIP path is left out and files are relative to that folder. - document.exportSettings, document.exportPresets.list and document.exportPresets.export: a kept folder outside the read roots is left out of the reply and of the presets file the export writes. - file.package: warnings, missingLinks and the report name a file outside the read roots by its file name. A link's stored path, read from the document's folder when it is relative, decides, also when its file was found under another path. - document.info: an image linked to a file outside the read roots has link null, and its Linked Images row names the file alone. docs/mcp.md and the params text describe the rule. --- crates/engine/src/cmd/docinfo.rs | 21 +++- crates/engine/src/cmd/fileio.rs | 10 +- crates/engine/src/cmd/fileio/access.rs | 23 +++++ crates/engine/src/cmd/fileio/commands.rs | 8 +- crates/engine/src/cmd/fileio/save.rs | 40 +++++++- crates/engine/src/cmd/fileio/screens.rs | 49 +++++++++- crates/engine/src/cmd/fileio/tests.rs | 13 +++ crates/engine/src/cmd/fileio/tests_screens.rs | 77 +++++++++++++++ crates/engine/src/cmd/package.rs | 39 +++++--- crates/engine/src/tests/package.rs | 97 +++++++++++++++++++ crates/engine/src/tests/save.rs | 44 +++++++++ crates/ui-egui/src/menus/run.rs | 18 +++- crates/ui-egui/src/tests/automation.rs | 21 ++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 15 files changed, 435 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 7499e14943..ff1fbe3591 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -1,7 +1,7 @@ //! Document Info panel data, its categories and the text report (Document Info › Save…, File → //! Package), and Object → Make Pixel Perfect. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { let mut fonts = BTreeSet::new(); let mut images: BTreeMap<String, Value> = BTreeMap::new(); let mut image_rows: Vec<ImageRow> = vec![]; + // Whether the request being served may read each linked file (a relative path read from the + // document's folder), checked once per file. + let mut readable: HashMap<String, bool> = HashMap::new(); + let link_doc = super::links::LinkBase::of(st).doc; + let mut may_read = |stored: &str| { + *readable.entry(stored.to_string()).or_insert_with(|| { + let resolved = super::fileio::document_path(stored, link_doc).ok(); + crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok() + }) + }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); @@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { Some("textObjects") } NodeKind::Image(im) => { + // A linked file outside a confined request's read roots: `link` is null, and the row + // names the file alone. + let link = im.link.as_ref().map(|l| (l, may_read(&l.path))); images.insert( im.key.clone(), - json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }), + json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }), ); let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string()); - image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone()))); + image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() }))); Some("images") } NodeKind::SymbolInstance { symbol, .. } => { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index eaa417f4ef..7eac419da9 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -124,7 +124,9 @@ pub(crate) use access::list_dir; pub use access::write_file_with; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; -pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; +pub(crate) use access::{ + absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, +}; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..7b20326106 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) { (out, seen) } +/// May a reply to the request being served name `path`, a file or folder the client did not give? +/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]). +pub(crate) fn shown(path: &str) -> bool { + crate::file_access::check_read(path).is_ok() +} + +/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name, +/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such +/// as `..` or a folder ending in a separator). +pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> { + shown_link(path, None) +} + +/// A link's `stored` path as a reply to the request being served names it: whole when the file it +/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it. +pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> { + if shown(resolved.unwrap_or(stored)) { + return std::borrow::Cow::Borrowed(stored); + } + let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default(); + std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 13df0d5eb1..83b6359786 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused", + "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused", has_doc, screens::export_for_screens ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Presets", [], None, - "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset", + "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset", always, screens::presets_list ), @@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Export Presets", [], None, - "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}", + "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}", always, screens::presets_export ), @@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export for Screens Settings", [], None, - "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)", + "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)", has_doc, screens::export_settings ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..cb8d83ad61 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1032,11 +1032,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let out = job.write_streamed(&mut super::write_file_with)?; + let mut out = job.write_streamed(&mut super::write_file_with)?; job.complete(s, uid); + leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) } +/// The reply `out` of a save with the `path` param `given`, as the request being served may see it +/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is +/// left out, and the `files` and `linked` written beside it are named by their file names, when they +/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it +/// is outside them. Without roots in force `out` is unchanged. +pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) { + use super::access::{shown, shown_path}; + + if crate::file_access::current().is_none() { + return; + } + let Some(o) = out.as_object_mut() else { return }; + if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) { + o.remove("folder"); + } + // Every path the reply names then is the one the client gave, or beside it. + if given.is_some_and(|g| !g.is_empty()) { + return; + } + if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) { + o.remove("path"); + } + for key in ["files", "linked"] { + if let Some(Value::Array(rows)) = o.get_mut(key) { + for row in rows { + if let Value::String(p) = row + && !shown(p) + { + *row = json!(shown_path(p)); + } + } + } + } +} + fn can_revert(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; match (&st.path, st.is_dirty()) { @@ -1104,7 +1140,7 @@ pub(super) fn specs() -> Vec<CommandSpec> { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 56c0d808a5..3974a574b1 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -644,12 +644,47 @@ impl Output { /// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with /// the params remembered in the document (`document.exportSettings`). pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { + // A folder the client gave, as against the one its `exportPreset` keeps. + let given = p.get("folder").is_some(); let p = with_preset(s, p)?; - let r = export(s, &p)?; + let mut r = export(s, &p)?; remember(s, &p); + if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { + leave_out_unreadable_folder(&mut r, folder); + } Ok(r) } +/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being +/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's +/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), +/// as the reply of an export to a ZIP names them. +fn leave_out_unreadable_folder(r: &mut Value, folder: &str) { + if super::access::shown(folder) { + return; + } + let Some(o) = r.as_object_mut() else { return }; + o.remove("path"); + let inside = format!("{folder}/"); + if let Some(Value::Array(files)) = o.get_mut("files") { + for file in files { + // A ZIP's files are named relative to it already. + if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) { + *file = json!(name); + } + } + } +} + +/// Export settings kept in the document or a preset, as the request being served may see them: a +/// `folder` outside the read roots ([`super::access::shown`]) is left out. +fn leave_out_unreadable_settings_folder(mut settings: Map<String, Value>) -> Map<String, Value> { + if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) { + settings.remove("folder"); + } + settings +} + // ---------- export presets ---------- /// A saved batch export: what to write and how to name and sort it, to use again @@ -727,7 +762,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value { } pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> { - Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) })) + let saved: Vec<ScreenExportPreset> = s + .prefs + .screen_export_presets + .iter() + .map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) }) + .collect(); + Ok(json!({ "presets": preset_list(&saved) })) } /// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would @@ -831,6 +872,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> { return Err(too_many()); } let count = presets.len(); + let presets: Vec<ScreenExportPreset> = + presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect(); let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?; if text.len() > MAX_PRESET_FILE_BYTES { return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes"))); @@ -1388,7 +1431,7 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map<String, Value>) /// `document.exportSettings`: the Export for Screens settings the document last exported with. pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> { - Ok(json!({ "settings": s.doc()?.doc.export_settings })) + Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) })) } #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 6236d281e2..ebe3b76da9 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject { } } +/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\` +/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole. +#[test] +fn a_hidden_path_shows_as_its_file_name_on_every_platform() { + let windows = "C:\\Users\\someone\\art\\red.png"; + crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || { + for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] { + assert_eq!(shown_path(path), name, "{path}"); + } + }); + assert_eq!(shown_path(windows), windows); +} + #[test] fn opens_every_readable_format() { let mut s = session(200.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index b0dcd368ec..b3e0642245 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -667,6 +667,83 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() { assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder"); } +/// An export into the folder an export preset keeps, for a client whose read roots don't hold that +/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder +/// inside the read roots, and one the client gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| confine(roots.as_ref(), || export(s, p)); + + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"})); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(out.join("1x/Artboard-1.png").is_file()); + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true})); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // Inside the read roots, or given by the client: whole. + assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")])); + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true})); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); +} + +/// The export settings the document keeps and the saved presets, read by a client whose read roots +/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and +/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. +/// Inside the read roots the folder stays. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn kept_settings_leave_out_a_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("settings-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + export(&mut s, json!({"exportPreset": "Kept"})); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap(); + let ask = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, cmd: &str, p: Value| { + confine(roots.as_ref(), || s.execute(cmd, &p).unwrap()) + }; + let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone(); + + let r = ask(&mut s, &confined, "document.exportSettings", json!({})); + assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}"); + let r = ask(&mut s, &confined, "document.exportPresets.list", json!({})); + assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}"); + let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]})); + let data = r["data"].as_str().unwrap(); + assert!(!data.contains(&folder) && data.contains("svg"), "{data}"); + let file = inside.join("kept.vcexportpresets"); + let path = file.to_string_lossy().replace('\\', "/"); + ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path})); + assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder)); + + // Inside the read roots: whole. + assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder)); + assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder)); + assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder)); +} + #[test] fn format_settings_apply_to_their_rows_and_rows_win() { let mut s = session(1); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index ee6d7e3c89..8d6f53d55b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; -use super::fileio::{create_dir, write_file}; +use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file}; use super::links::LinkBase; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> { "Package", [], None, - "{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder", + "{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder. The warnings and the report name a file outside the automation read roots by its file name", has_doc, package )] @@ -178,8 +178,10 @@ impl<'a> Collector<'a> { /// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the /// copy of `source` finds it only by its name, beside it. + /// `path` is the link's path as the warning names it. fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) { if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) { + let source = shown_path(source); let message = format!( "{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview" ); @@ -207,11 +209,13 @@ impl<'a> Collector<'a> { let section = self.sections.len(); self.sections.push(vec![]); let mut lines = vec![]; - // The packaged document's sections read as before; a placed document's name its file. + // The packaged document's sections read as before; a placed document's name its file. The + // report and the warnings name a file outside a confined request's read roots by its name. + let shown_source = shown_path(source); let heading = |lines: &mut Vec<String>, title: &str| { lines.push(title.into()); if depth > 0 { - lines.push(format!("Document: {source}")); + lines.push(format!("Document: {shown_source}")); } }; // A file's saved path is only meaningful inside its own document. @@ -243,17 +247,21 @@ impl<'a> Collector<'a> { heading(&mut lines, "LINKED FILES"); for file in linked_files(doc) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); + // The stored path, whole when the place it names (read from this document's folder) is + // inside the read roots. A file found elsewhere does not show a stored path outside them. + let resolved = document_path(&file.path, Some(source)).ok(); + let shown = shown_link(&file.path, resolved.as_deref()).into_owned(); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A // cyclic reference cannot have a final content hash until both files // have been written, so leave its size/hash unspecified, not stale. - self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path)); + self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle")); local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); continue; } if let Some(existing) = self.copies.get(found_path).cloned() { if by_name { - self.renamed(&mut lines, source, &file.path, &file.name, &existing.name); + self.renamed(&mut lines, source, &shown, &file.name, &existing.name); } // Another document may place other pages of a PDF copied already. if self.opts.copy_fonts @@ -265,8 +273,8 @@ impl<'a> Collector<'a> { continue; } let Some(mut bytes) = file.bytes else { - lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); - self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) }); + lines.push(format!("{}: not found ({shown}), not copied", file.name)); + self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) }); continue; }; if self.copies.len() >= 10_000 { @@ -277,7 +285,7 @@ impl<'a> Collector<'a> { let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); if by_name { - self.renamed(&mut lines, source, &file.path, &file.name, &destination_file); + self.renamed(&mut lines, source, &shown, &file.name, &destination_file); } // A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file // read from its editing data links are copied beside it. @@ -285,7 +293,7 @@ impl<'a> Collector<'a> { let placed_file = pages.is_some(); if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links")); } else { self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?; } @@ -296,7 +304,7 @@ impl<'a> Collector<'a> { } if placed_file && super::place::document::is_native(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links")); } else { match vectorcraft_format::load_file(&bytes) { Ok(saved) => { @@ -320,8 +328,7 @@ impl<'a> Collector<'a> { Err(e) => self.warn( &mut lines, format!( - "{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked", - file.path + "{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked" ), ), } @@ -329,7 +336,7 @@ impl<'a> Collector<'a> { } Err(e) => self.warn( &mut lines, - format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path), + format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"), ), } } @@ -337,7 +344,7 @@ impl<'a> Collector<'a> { let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) }; self.copies.insert(found_path.to_string(), copy.clone()); local.insert(file.path.clone(), copy); - lines.push(format!("{} → {destination_file}", file.path)); + lines.push(format!("{shown} → {destination_file}")); self.entries.push((destination_file, bytes)); } lines.push(String::new()); @@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res // The report. if let Some(info) = info { let report_name = free_name(&format!("{stem} Report.txt"), &mut taken); - let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n"); + let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path)); lines.iter().for_each(|l| text.push_str(&format!("{l}\n"))); text.push_str(&info); entries.push((report_name, text.into_bytes())); diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index 01550b5ad1..c1da365740 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +/// Packaged for a client whose read root holds neither the document's file nor its links: the report, +/// its Document Info part included, names them by their file names, and `document.info` gives no +/// link. The person's report names them whole. +#[test] +fn a_confined_package_names_files_outside_the_read_root_by_name() { + let dir = Folder::new("package-roots"); + let mut s = poster(&dir); + std::fs::create_dir_all(dir.file("in")).unwrap(); + std::fs::remove_file(dir.file("art/blue.png")).unwrap(); + open(&mut s, &dir.file("poster.vectorcraft")); + let report = |r: &Value| { + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap() + }; + let person = report(&run(&mut s, "file.package", json!({}))); + assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}"); + assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}"); + assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}"); + + let in_root = dir.0.join("in"); + let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap(); + let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let text = report(&client); + assert!(text.contains("From: poster.vectorcraft\n"), "{text}"); + assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}"); + let base = dir.0.to_string_lossy().into_owned(); + assert!(!text.contains(&base), "{text}"); + for key in ["missingLinks", "warnings", "files"] { + assert!(!client[key].to_string().contains(&base), "{key}: {client}"); + } + // Document Info, whose report the package holds, gives no link either. + for p in [json!({}), json!({"format": "text"})] { + let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone())); + assert!(!info.to_string().contains(&base), "{p}: {info}"); + } + let info = run(&mut s, "document.info", json!({"format": "text"})); + assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}"); +} + +/// A missing link stored as a path relative to the document names a file inside a confined +/// request's read root: the package report and Document Info give its stored path. +#[test] +fn a_relative_link_inside_the_read_root_keeps_its_path() { + let dir = Folder::new("package-roots-relative"); + write(&dir.file("art/near.png"), &png(8, 8, RED)); + let mut s = session(); + let id = place(&mut s, &dir.file("art/near.png")); + let mut doc = (*s.doc().unwrap().doc).clone(); + let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") }; + im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png")); + write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false)); + // Missing, the link keeps its stored path when the document opens (the package copies the + // image the document holds). + std::fs::remove_file(dir.file("art/near.png")).unwrap(); + open(&mut s, &dir.file("near.vectorcraft")); + let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap(); + assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}"); + let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({}))); + let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect(); + assert_eq!(links, [&json!("art/near.png")], "{info}"); + let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"}))); + assert!(text.to_string().contains("art/near.png"), "{text}"); +} + +/// A link whose stored path names a place outside a confined request's read root, with its file +/// found by name beside the document inside the root: the package report and reply give the file +/// name, not the stored path. +#[test] +fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() { + let dir = Folder::new("package-roots-stale"); + write(&dir.file("in/near.png"), &png(8, 8, RED)); + let mut s = session(); + let id = place(&mut s, &dir.file("in/near.png")); + let mut doc = (*s.doc().unwrap().doc).clone(); + let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") }; + let stale = dir.file("out/near.png"); + im.link = Some(vectorcraft_doc::LinkInfo::new(&stale)); + write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false)); + // Missing when the document opens, the link keeps its stored path; the file then appears beside it. + std::fs::remove_file(dir.file("in/near.png")).unwrap(); + open(&mut s, &dir.file("in/near.vectorcraft")); + write(&dir.file("in/near.png"), &png(8, 8, RED)); + let in_root = dir.0.join("in"); + let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap(); + assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}"); + assert!(!text.contains(&stale), "{text}"); + for key in ["missingLinks", "warnings", "files"] { + assert!(!r[key].to_string().contains(&stale), "{key}: {r}"); + } +} + /// A file missing from a document that moved keeps the place its relative path names from the /// document's folder: the packaged document writes its relative path from there. #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index d92365a356..228d09d315 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize { s.doc().unwrap().doc.node_count() } +/// A client confined by automation roots: a save without a path writes the document's own file, and +/// when that file is outside the read roots the reply leaves out its `path` and names the files +/// written beside it by their file names. A reply without a path leaves out a suggested `folder` +/// outside them. A path inside the read roots, and one the client gives, stay whole. +#[test] +fn a_confined_save_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots"); + let (inside, outside) = (d.join("in"), d.join("out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap(); + let files: Vec<String> = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}"); + + let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap(); + let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, id: &str, p: Value| { + confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}")) + }; + let hidden = outside.to_string_lossy().into_owned(); + let r = run(&mut s, &write_only, "document.save", json!({})); + assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true})); + let names: Vec<Value> = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect(); + assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}"); + // Inside the read roots, or given by the client: whole. + assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own)); + assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own)); + // A save without a path known suggests the document's folder. + let r = run(&mut s, &write_only, "file.saveCopy", json!({})); + assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}"); + assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden)); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 3030ef2c61..1c2fca8230 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -122,10 +122,20 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< "file.openWith" => io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), - "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), - "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), - "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), + // A confined request sees the document's own file, and the folder a save suggests, only + // inside its read roots. + "file.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => { + let mode = match id { + "file.save" => SaveMode::Save, + "file.saveAs" => SaveMode::SaveAs, + "file.saveCopy" => SaveMode::Copy, + _ => SaveMode::Template, + }; + io::save(app, mode, p, true).map(|mut r| { + vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str)); + r + }) + } "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index ee876b2e71..a459c39d55 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -107,6 +107,27 @@ fn control_saves_take_the_native_save_options() { let _ = std::fs::remove_dir_all(base); } +/// A save without a path writes the document's own file in the write root: `app.save` and +/// `file.save` leave its path out of the reply, since it is outside the read root, and keep a path +/// the client gives. +#[test] +fn control_saves_leave_out_the_documents_file_outside_the_read_root() { + let (mut app, tx, base) = confined("save-own"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + for (method, params) in [("app.save", json!({})), ("engine.execute", json!({"command": "file.save", "params": {}}))] { + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, method, params); + assert_eq!(r["ok"], true, "{method}: {r}"); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{method}: {r}"); + assert_eq!(app.session.active().map(|d| d.is_dirty()), Some(false), "{method}: saved"); + } + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..5f02c87f2e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -123,6 +123,17 @@ the suite) keep the agent to the folders you give it: - Every file a command writes must lie inside a **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, the copies `links.relink {copy}` makes. +- A path the agent gives stays in a save or export reply, with the files written beside it. Paths it did not + give are left out when they are outside the read roots. `save_file`, `document.save` and `file.save` without + a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files + written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without + one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` + of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder. + `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as + a file) leave out a kept `folder` outside the read roots. `file.package` + names the document's file and the linked files outside the read roots by their file names in `warnings`, in + `missingLinks` and in its report. `document.info` gives `link: null` for an image linked to a file outside them, + and its Linked Images rows and text report name that file alone. `action.batch` returns the replies of its steps as these commands give them. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163). - `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..56b960dedb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,6 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. + - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 987dd18aff291b18cc5968a4583683eb83ab5864 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:41:25 -0800 Subject: [PATCH 049/126] File formats: HP-GL (.plt) plots export for pen plotters and cutters VectorCraft could open and place HP-GL plot files but not write them, so art for a pen plotter or a vinyl cutter had to go through another program. Export As, document.export and Export Selection now write HP-GL. vectorcraft_cad::hpgl::export draws each visible path once along its own line: a filled shape is its closed outline and a stroked path its center line. Type is drawn as glyph outlines, a gradient mesh as its outline, and a symbol instance as its art with its effects and the instance's stain. Clipping masks cut the art they clip, and the art is cut at the artboard's edges. Hidden and non-printing layers, hidden objects, objects at 0% opacity, template layers, guides and placed images are left out. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner or its center, and art more than 8,388,607 units from the origin is cut off. Each color is a pen, in the order the plot first draws in it (up to 255), or a pen table maps each path to the pen of the nearest color. With Use Artboards every file of the export uses the same numbering. The pens draw in turn, each in paint order or, with sortPaths, nearest path first. Curves are flattened to curveTolerance (0.1 pt by default). hpglVersion "hpgl" writes IN, SP, PU and PD; "hpgl2" also writes the palette (NP, PC) and each pen's paths as one encoded polyline (PE). Options are checked, and Export As writes the file with the defaults. Plots read back by the HP-GL reader give the same lines. Docs: docs/mcp.md, docs/control-protocol.md, docs/file-format-parity.md, docs/target-app-parity.md. --- crates/cad/Cargo.toml | 2 +- crates/cad/src/hpgl.rs | 7 + crates/cad/src/hpgl/write.rs | 1033 ++++++++++++++++++ crates/cad/src/hpgl/write/clip.rs | 265 +++++ crates/cad/src/hpgl/write/tests.rs | 936 ++++++++++++++++ crates/cad/src/lib.rs | 6 +- crates/engine/src/cmd/fileio.rs | 2 + crates/engine/src/cmd/fileio/action_batch.rs | 1 + crates/engine/src/cmd/fileio/commands.rs | 6 +- crates/engine/src/cmd/fileio/encode.rs | 5 +- crates/engine/src/cmd/fileio/formats.rs | 16 +- crates/engine/src/cmd/fileio/hpgl.rs | 119 ++ crates/engine/src/cmd/fileio/screens.rs | 2 +- crates/engine/src/cmd/fileio/tests.rs | 2 + crates/engine/src/cmd/fileio/tests_hpgl.rs | 115 +- crates/engine/src/tests/areafit.rs | 2 +- crates/mcp/src/tests/fileio.rs | 1 + crates/mcp/src/tools/definitions.rs | 4 +- crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/dialogs/tests_hpgl.rs | 28 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/control-protocol.md | 3 + docs/file-format-parity.md | 2 +- docs/mcp.md | 46 +- docs/target-app-parity.md | 1 + 51 files changed, 2611 insertions(+), 23 deletions(-) create mode 100644 crates/cad/src/hpgl/write.rs create mode 100644 crates/cad/src/hpgl/write/clip.rs create mode 100644 crates/cad/src/hpgl/write/tests.rs create mode 100644 crates/engine/src/cmd/fileio/hpgl.rs create mode 100644 crates/ui-egui/src/dialogs/tests_hpgl.rs diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 3e262b0764..34f77ec929 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader and writer" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs index 5016e301f6..9494fbee46 100644 --- a/crates/cad/src/hpgl.rs +++ b/crates/cad/src/hpgl.rs @@ -16,11 +16,18 @@ //! and the art of every page goes on one artboard; each comes back as a warning. Printer escape //! sequences and PJL lines around the instructions are skipped. //! +//! [`export`] writes the visible art over a region as an HP-GL or HP-GL/2 plot, and +//! [`export_regions`] one plot per region with one pen numbering: see [`write`]. +//! //! - `lex`: the bytes as instructions, numbers, labels and PE data. //! - `plot`: the instructions played on the pen, as shapes. +//! - `write`: the writer, paths to pen moves. mod lex; mod plot; +pub mod write; + +pub use write::{HpglOptions, HpglOutput, Origin, Version, export, export_regions}; use std::sync::Arc; diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs new file mode 100644 index 0000000000..9e77495aa2 --- /dev/null +++ b/crates/cad/src/hpgl/write.rs @@ -0,0 +1,1033 @@ +//! HP-GL export: the visible art over a region as pen moves, for pen plotters and cutters. +//! +//! Every visible path is drawn once along its own line: its fills and strokes are not painted, so +//! a filled shape is its closed outline (what a cutter cuts) and a stroke is its center line. Each +//! color is a pen: by default pen 1 is the first color the plot draws in, pen 2 the next new one, +//! and so on; with a pen table each path takes the pen whose color is nearest its own. The plots +//! of one export ([`export_regions`]) share the numbering. A path's color is its topmost visible +//! stroke's, else its topmost visible fill's (one at 0% opacity does not count); a gradient is the +//! average of its stops and a pattern its first visible solid fill (gray without one). Curves are +//! flattened to straight moves within a tolerance. Type is drawn as its glyph outlines, a gradient +//! mesh as its outline in the average of its shown points' colors, clipping masks cut what they clip, +//! and the art is cut at the region's edges. Hidden and non-printing layers, hidden objects, +//! objects at 0% opacity, template layers, guides and placed images are left out. +//! +//! Coordinates are plotter units (1016 to the inch, y up) from the region's bottom-left corner or +//! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range +//! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. +//! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with +//! `sort`, each next path the one that starts nearest where the pen stopped. +//! +//! HP-GL writes `IN;`, then for each pen `PU;SP<n>;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`. +//! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths +//! as one encoded polyline (`PE`, base 64). + +mod clip; + +use std::collections::HashSet; +use std::sync::Arc; + +use kurbo::PathEl; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, SymbolBoxes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect}; + +use crate::MAX_NEST; +use clip::Region; + +/// Plotter units per point. +const PLU_PER_PT: f64 = 1016.0 / 72.0; +/// The largest coordinate written, plotter units. +pub const MAX_COORD: i64 = (1 << 23) - 1; +/// The curve tolerance a path is flattened to by default, points. +pub const DEFAULT_TOLERANCE: f64 = 0.1; +/// The smallest and largest curve tolerance, points. +pub const TOLERANCE_RANGE: std::ops::RangeInclusive<f64> = 0.01..=10.0; +/// The most pens: a pen table's length, and the colors that get a pen of their own. +pub const MAX_PENS: usize = 255; +/// The most points the plots of one export hold. +const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 }; +/// The most point comparisons sorting may make, over the whole plot. +const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 }; +/// The most edge tests clipping masks may take, over the whole plot. +const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 }; +/// The most points the clipping masks around an object may hold, together. +const MAX_MASK_POINTS: usize = if cfg!(test) { 10_000 } else { 10_000_000 }; +/// The most objects live art (blends, envelopes, repeats) may make for a plot, together, as one +/// evaluation of live art may make ([`vectorcraft_doc::live::MAX_GENERATED`]). +const MAX_GENERATED: u64 = if cfg!(test) { 1_000 } else { vectorcraft_doc::live::MAX_GENERATED as u64 }; + +const TEXT_OUTLINES: &str = "type is plotted as its glyph outlines"; +const INLINE_GRAPHICS: &str = "inline graphics in type are left out"; +const IMAGES: &str = "placed images are left out: a pen draws only lines"; +const MASKS: &str = "opacity masks are left out"; +const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out"; +const STROKE_LOOK: &str = "stroke widths, dashes, brushes and arrowheads are left out: each path is drawn once along its own line"; +const TOO_MANY_POINTS: &str = "the export is too large: the art past its first 10 million points was left out"; +const CLIP_WORK: &str = "clipping masks are too complex to apply in full: the art past that point is drawn whole"; +const MASK_POINTS: &str = "clipping masks past 10 million points together are left out: the art they clip is drawn unclipped by them"; +const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; +const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; +const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; +const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; +const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out"; +const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out"; + +/// The language written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Version { + /// `IN`, `SP`, `PU` and `PD` only. + #[default] + Hpgl, + /// HP-GL/2: pen colors (`NP`, `PC`) and encoded polylines (`PE`). + Hpgl2, +} + +impl Version { + pub const ALL: [Self; 2] = [Self::Hpgl, Self::Hpgl2]; + + /// The `hpglVersion` param value. + pub fn id(self) -> &'static str { + match self { + Self::Hpgl => "hpgl", + Self::Hpgl2 => "hpgl2", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Hpgl => "HP-GL", + Self::Hpgl2 => "HP-GL/2", + } + } + + /// A version by id (`hpgl`, `hpgl2`; `hp-gl` and `hp-gl/2` also read), any case. + pub fn from_id(s: &str) -> Option<Self> { + let s = s.trim(); + Self::ALL.into_iter().find(|v| s.eq_ignore_ascii_case(v.id()) || s.eq_ignore_ascii_case(v.label())) + } +} + +/// Where the plot's origin (0, 0) lies on the region. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Origin { + #[default] + BottomLeft, + Center, +} + +impl Origin { + pub const ALL: [Self; 2] = [Self::BottomLeft, Self::Center]; + + /// The `origin` param value. + pub fn id(self) -> &'static str { + match self { + Self::BottomLeft => "bottomLeft", + Self::Center => "center", + } + } + + /// An origin by id, any case. + pub fn from_id(s: &str) -> Option<Self> { + Self::ALL.into_iter().find(|o| s.trim().eq_ignore_ascii_case(o.id())) + } +} + +/// The HP-GL export settings. +#[derive(Clone, Debug)] +pub struct HpglOptions { + pub version: Version, + /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]). + pub tolerance: f64, + /// The pen table: pen 1's color first. Empty: a pen for each color the art draws in. + pub pens: Vec<[u8; 3]>, + pub origin: Origin, + /// Draw each pen's paths nearest first, instead of in paint order. + pub sort: bool, + /// The area plotted (document space): its bottom-left corner or center is the origin, and the + /// art is cut at its edges. + pub region: Rect, +} + +impl Default for HpglOptions { + fn default() -> Self { + Self { version: Version::default(), tolerance: DEFAULT_TOLERANCE, pens: vec![], origin: Origin::default(), sort: false, region: Rect::ZERO } + } +} + +/// A written plot. +#[derive(Clone, Debug, Default)] +pub struct HpglOutput { + pub bytes: Vec<u8>, + /// The pens' colors, pen 1's first. + pub pens: Vec<[u8; 3]>, + /// What was approximated or left out. + pub warnings: Vec<String>, +} + +/// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are +/// applied first. +pub fn export(doc: &Document, opts: &HpglOptions) -> Result<HpglOutput, String> { + Ok(export_regions(doc, opts, &[opts.region])?.pop().unwrap_or_default()) +} + +/// Write the visible art of `doc` over each of `regions` as an HP-GL plot, one per region, as +/// [`export`] does with `opts.region`. The plots share one pen numbering, so a color is the same +/// pen in each, and one point budget. +pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> Result<Vec<HpglOutput>, String> { + let regions: Vec<Rect> = regions.iter().map(|r| r.abs()).collect(); + if regions.iter().any(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err("the export region is empty".into()); + } + let tolerance = + if opts.tolerance.is_finite() { opts.tolerance.clamp(*TOLERANCE_RANGE.start(), *TOLERANCE_RANGE.end()) } else { DEFAULT_TOLERANCE }; + // Layers the plot leaves out go first: baking can wrap one in art that would be drawn (its + // crop marks). + let shown = plotted(&doc.layers, false).map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); + let doc = shown.as_ref().unwrap_or(doc); + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); + // Each plot's lines in plotter units, with the points a move doesn't change dropped, and its + // warnings. + let mut plots: Vec<(Lines, Vec<String>)> = Vec::with_capacity(regions.len()); + let mut points = 0; + for r in regions { + // Document space to plotter units, y up. + let o = match opts.origin { + Origin::BottomLeft => Point::new(r.x0, r.y1), + Origin::Center => r.center(), + }; + let span = MAX_COORD as f64 / PLU_PER_PT; + let range = Rect::new(o.x - span, o.y - span, o.x + span, o.y + span); + let mut walk = Walk::new(doc, r.intersect(range), tolerance); + walk.points = points; + // Only a region past the range can have art the range cuts off. + if r.intersect(range) != r { + walk.range = Some((range, r)); + walk.asked = Region::new(vec![corners(r)], FillRule::NonZero); + } + for l in &doc.layers { + walk.node(l, false); + } + points = walk.points; + let Walk { runs, warnings, .. } = walk; + let to_plu = |p: Point| { + let x = ((p.x - o.x) * PLU_PER_PT).round().clamp(-(MAX_COORD as f64), MAX_COORD as f64) as i64; + let y = ((o.y - p.y) * PLU_PER_PT).round().clamp(-(MAX_COORD as f64), MAX_COORD as f64) as i64; + (x, y) + }; + let lines = runs + .into_iter() + .filter_map(|run| { + let mut pts: Vec<(i64, i64)> = Vec::with_capacity(run.points.len()); + for p in run.points { + let q = to_plu(p); + if pts.last() != Some(&q) { + pts.push(q); + } + } + // A line shorter than a plotter unit draws nothing and takes no pen. + (pts.len() >= 2).then_some((run.rgb, pts)) + }) + .collect(); + plots.push((lines, warnings)); + } + let mut pen_warnings = vec![]; + let colors: Vec<[u8; 3]> = plots.iter().flat_map(|(lines, _)| lines.iter().map(|(c, _)| *c)).collect(); + let (pens, of_line) = assign_pens(&colors, &opts.pens, &mut pen_warnings); + let mut of_line = of_line.into_iter(); + let mut out = Vec::with_capacity(plots.len()); + for (lines, mut warnings) in plots { + let mut by_pen: Vec<Vec<Vec<(i64, i64)>>> = vec![vec![]; pens.len()]; + for ((_, pts), pen) in lines.into_iter().zip(of_line.by_ref()) { + if let Some(paths) = by_pen.get_mut(pen) { + paths.push(pts); + } + } + if !by_pen.iter().all(Vec::is_empty) { + pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); + } + if opts.sort { + let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); + for paths in &mut by_pen { + let (stop, sorted) = nearest_first(paths, at, &mut work); + at = stop; + if !sorted { + warn(&mut warnings, UNSORTED); + } + } + } + let bytes = match opts.version { + Version::Hpgl => plain(&by_pen), + Version::Hpgl2 => encoded(&by_pen, &pens), + }; + out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + } + Ok(out) +} + +/// A plot's lines in plotter units, each with its color. +type Lines = Vec<([u8; 3], Vec<(i64, i64)>)>; + +fn warn(warnings: &mut Vec<String>, w: &str) { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } +} + +/// The corners of `r`, as a clipping ring. +fn corners(r: Rect) -> Vec<Point> { + vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] +} + +/// Does `r` hold `p`, its edges included? +fn holds(r: Rect, p: Point) -> bool { + p.x >= r.x0 && p.x <= r.x1 && p.y >= r.y0 && p.y <= r.y1 +} + +/// The pens and each line's pen (0-based), from the lines' `colors`: with a table, the table and +/// the nearest of its colors; else each new color a pen of its own, up to [`MAX_PENS`], and the +/// nearest after that. +fn assign_pens(colors: &[[u8; 3]], table: &[[u8; 3]], warnings: &mut Vec<String>) -> (Vec<[u8; 3]>, Vec<usize>) { + let mut pens: Vec<[u8; 3]> = table.iter().take(MAX_PENS).copied().collect(); + let fixed = !pens.is_empty(); + let mut of_line = Vec::with_capacity(colors.len()); + for &rgb in colors { + let pen = match pens.iter().position(|c| *c == rgb) { + Some(i) => i, + None if !fixed && pens.len() < MAX_PENS => { + pens.push(rgb); + pens.len() - 1 + } + None => { + if !fixed { + warn(warnings, MANY_COLORS); + } + nearest(&pens, rgb) + } + }; + of_line.push(pen); + } + (pens, of_line) +} + +/// The index of the color in `pens` nearest `c` (the first of equally near ones). +fn nearest(pens: &[[u8; 3]], c: [u8; 3]) -> usize { + let d = |p: &[u8; 3]| (0..3).map(|i| (i32::from(p[i]) - i32::from(c[i])).pow(2)).sum::<i32>(); + pens.iter().enumerate().min_by_key(|(_, p)| d(p)).map_or(0, |(i, _)| i) +} + +fn dist2(a: (i64, i64), b: (i64, i64)) -> i128 { + let (dx, dy) = (i128::from(a.0) - i128::from(b.0), i128::from(a.1) - i128::from(b.1)); + dx * dx + dy * dy +} + +/// The squared distance from `p` to box `b` (`[x0, y0, x1, y1]`), 0 inside it. +fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { + let d = |v: i64, lo: i64, hi: i64| i128::from(lo.saturating_sub(v).max(v.saturating_sub(hi)).max(0)); + let (dx, dy) = (d(p.0, b[0], b[2]), d(p.1, b[1], b[3])); + dx * dx + dy * dy +} + +/// `paths` reordered from `at`: each next path is the one nearest where the pen stopped, by its +/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its +/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep +/// their order. Returns where the pen stops, and `false` when the work ran out. +fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) { + // Each path with its box. + let mut left: Vec<_> = std::mem::take(paths) + .into_iter() + .map(|p| { + let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]); + (p, b) + }) + .collect(); + while !left.is_empty() { + // (index, reversed, the closed path's point to start at, distance) + let mut best: (usize, bool, Option<usize>, i128) = (0, false, None, i128::MAX); + for (i, (p, b)) in left.iter().enumerate() { + let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; + *work = work.saturating_sub(1); + if s == e && p.len() > 2 { + if box_dist2(at, *b) >= best.3 { + continue; + } + *work = work.saturating_sub(p.len() as u64); + if let Some((k, d)) = p.iter().enumerate().map(|(k, q)| (k, dist2(at, *q))).min_by_key(|(_, d)| *d) + && d < best.3 + { + best = (i, false, Some(k), d); + } + continue; + } + let (ds, de) = (dist2(at, s), dist2(at, e)); + if ds < best.3 { + best = (i, false, None, ds); + } + if de < best.3 { + best = (i, true, None, de); + } + } + if *work == 0 { + paths.extend(left.into_iter().map(|(p, _)| p)); + return (at, false); + } + let (mut p, _) = left.remove(best.0); + if best.1 { + p.reverse(); + } else if let Some(k) = best.2 { + // A closed path starts at its point nearest the pen. + p.pop(); + let k = k.min(p.len().saturating_sub(1)); + p.rotate_left(k); + if let Some(&s) = p.first() { + p.push(s); + } + } + if let Some(&e) = p.last() { + at = e; + } + paths.push(p); + } + (at, true) +} + +/// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`. +fn plain(by_pen: &[Vec<Vec<(i64, i64)>>]) -> Vec<u8> { + let mut s = String::from("IN;"); + for (i, paths) in by_pen.iter().enumerate().filter(|(_, p)| !p.is_empty()) { + s.push_str(&format!("PU;SP{};", i + 1)); + for path in paths { + let Some(((x, y), rest)) = path.split_first() else { continue }; + s.push_str(&format!("PU{x},{y};PD")); + for (k, (x, y)) in rest.iter().enumerate() { + if k > 0 { + s.push(','); + } + s.push_str(&format!("{x},{y}")); + } + s.push(';'); + } + } + s.push_str("PU;SP0;"); + s.into_bytes() +} + +/// HP-GL/2: `IN;`, the palette, each pen's paths as one `PE`, `PU;SP0;`. +fn encoded(by_pen: &[Vec<Vec<(i64, i64)>>], pens: &[[u8; 3]]) -> Vec<u8> { + let mut out = b"IN;".to_vec(); + if !pens.is_empty() { + // Pens 1 to n on a plotter, and on a raster device (whose pens are 0 to NP − 1) too. + let np = (pens.len() + 1).next_power_of_two().max(2); + out.extend(format!("NP{np};").bytes()); + for (i, [r, g, b]) in pens.iter().enumerate() { + out.extend(format!("PC{},{r},{g},{b};", i + 1).bytes()); + } + } + let mut at: Option<(i64, i64)> = None; + for (i, paths) in by_pen.iter().enumerate().filter(|(_, p)| !p.is_empty()) { + out.extend(format!("PU;SP{};PE", i + 1).bytes()); + for path in paths { + for (k, &p) in path.iter().enumerate() { + if k == 0 { + out.push(b'<'); + } + // The first move of the plot is absolute, and so is one longer than the range a + // device takes for each number; every other is relative. + match at.map(|a| (p.0 - a.0, p.1 - a.1)).filter(|d| d.0.abs() <= MAX_COORD && d.1.abs() <= MAX_COORD) { + None => { + out.push(b'='); + pe_number(&mut out, p.0); + pe_number(&mut out, p.1); + } + Some((dx, dy)) => { + pe_number(&mut out, dx); + pe_number(&mut out, dy); + } + } + at = Some(p); + } + } + out.push(b';'); + } + out.extend(b"PU;SP0;"); + out +} + +/// `v` as a base-64 PE number: the sign in the low bit, the low digits from 63 up and the last +/// digit from 191 up. +fn pe_number(out: &mut Vec<u8>, v: i64) { + let mut n = (v.unsigned_abs() << 1) | u64::from(v < 0); + while n >= 64 { + out.push(63 + (n % 64) as u8); + n /= 64; + } + out.push(191 + n as u8); +} + +/// One line the pen draws, in document space, and its color. +struct Run { + rgb: [u8; 3], + points: Vec<Point>, +} + +/// The document walk: each visible path becomes runs, clipped, in paint order. +struct Walk<'a> { + doc: &'a Document, + symbols: SymbolBoxes<'a>, + /// The region the art is cut at. + region: Rect, + edge: Option<Region>, + /// The plotter's range and the region asked for, when that reaches past the range. + range: Option<(Rect, Rect)>, + /// The region asked for, to cut a line at before testing it against the range. + asked: Option<Region>, + tolerance: f64, + /// The clipping masks around the current object, outermost first. + clips: Vec<Region>, + /// The points those masks hold ([`MAX_MASK_POINTS`]). + mask_points: usize, + runs: Vec<Run>, + points: usize, + clip_tests: u64, + warnings: Vec<String>, + nest: u32, + /// Objects the symbol instances written held, together ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). + symbol_nodes: u64, + /// Objects the live art written made, together ([`MAX_GENERATED`]). + generated: u64, + /// A copy of the document without its layers, to bake symbol art in ([`Walk::baked`]). + scratch: Option<Document>, + /// The symbols whose art holds geometry effects ([`Walk::baked`]), or instances of such + /// symbols. + effect_symbols: HashSet<String>, +} + +impl<'a> Walk<'a> { + fn new(doc: &'a Document, region: Rect, tolerance: f64) -> Self { + Self { + doc, + symbols: doc.symbol_boxes().quick(), + region, + edge: Region::new(vec![corners(region)], FillRule::NonZero), + range: None, + asked: None, + tolerance, + clips: vec![], + mask_points: 0, + runs: vec![], + points: 0, + clip_tests: 0, + warnings: vec![], + nest: 0, + symbol_nodes: 0, + generated: 0, + scratch: None, + effect_symbols: effect_symbols(doc), + } + } + + fn warn(&mut self, w: &str) { + warn(&mut self.warnings, w); + } + + fn full(&self) -> bool { + self.points >= MAX_POINTS + } + + fn node(&mut self, n: &Node, force: bool) { + if n.is_ruler_guide() || n.is_template() { + return; + } + if self.full() { + self.warn(TOO_MANY_POINTS); + return; + } + if !force && !n.visible { + return; + } + // Art at no opacity shows nothing, and nor does anything inside it. + if n.opacity <= 0.0 { + return; + } + // Bounds don't measure the effects in symbol art, which apply only once an instance is + // placed: an instance of such art, and what holds one, is not skipped by its bounds. + let unmeasured = !self.effect_symbols.is_empty() && holds_instance_of(n, &self.effect_symbols); + // The box round all the object can paint, a blend's steps on its spine included. + if !n.is_layer() && !unmeasured { + match n.reach_bounds_with(&self.symbols) { + Some(b) if !overlaps(b, self.region) => { + // Art over the asked-for region but past the plotter's range is cut off. + if self.range.is_some_and(|(_, whole)| overlaps(b, whole)) { + self.warn(OUT_OF_RANGE); + } + return; + } + None if !n.is_container() => return, + _ => {} + } + } + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(MASKS); + } + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); + if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) { + self.warn(RASTER_FX); + } + match &n.kind { + // Non-printing layers aren't plotted. + NodeKind::Layer { children, printable, clip, .. } => { + if *printable { + self.children(children, *clip); + } + } + NodeKind::Group { children, clip } => self.children(children, *clip), + NodeKind::Path { path, guide, .. } => { + if !*guide { + self.shape(n, &path.to_bezpath()); + } + } + NodeKind::Compound { children, .. } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + self.shape(n, &bp); + } + NodeKind::Text(t) => { + if t.runs.iter().any(|r| r.inline.is_some()) { + self.warn(INLINE_GRAPHICS); + } + if let Some(o) = vectorcraft_effects::outline_text(n) { + self.warn(TEXT_OUTLINES); + self.node(&o, true); + } + } + NodeKind::Image(_) => self.warn(IMAGES), + NodeKind::SymbolInstance { symbol, xf } => self.symbol(n, symbol, *xf), + // A gradient mesh is its outline, in the average of its shown points' colors: its + // patches are shading, not lines. A mesh whose every point is at 0% opacity shows + // nothing. + NodeKind::Mesh(m) => { + let shown: Vec<[u8; 3]> = m.points.iter().filter(|p| p.opacity > 0.0).map(|p| rgb8(&p.color)).collect(); + if !shown.is_empty() { + self.draw(average(shown.into_iter()), &m.outline().to_bezpath(), true); + } + } + // Live blends, envelopes and repeats are drawn as their evaluated art, a level at a + // time, so a gradient mesh a blend makes is its outline too. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + if self.nest >= MAX_NEST { + self.warn(TOO_DEEP); + return; + } + // Once the budget is spent, nothing more is evaluated. + if self.generated >= MAX_GENERATED { + self.warn(LIVE_LEFT_OUT); + return; + } + let art = vectorcraft_effects::expand_live(Some(self.doc), n); + let count = art.iter().map(|c| c.count() as u64).sum::<u64>(); + if self.generated.saturating_add(count) > MAX_GENERATED { + self.generated = MAX_GENERATED; + self.warn(LIVE_LEFT_OUT); + return; + } + self.generated += count; + self.nest += 1; + art.iter().for_each(|c| self.node(c, false)); + self.nest -= 1; + } + NodeKind::RulerGuide { .. } => {} + } + } + + /// A symbol instance's art, placed (clip groups included), within the budget for the export. + fn symbol(&mut self, n: &Node, symbol: &str, xf: Affine) { + let doc = self.doc; + if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { + self.warn(SYMBOLS_LEFT_OUT); + return; + } + let Some(sym) = doc.symbols.iter().find(|s| s.name == symbol) else { return }; + if self.nest >= MAX_NEST { + self.warn(TOO_DEEP); + return; + } + // The instance's own changes and its stain, as the canvas draws it. + let art = vectorcraft_brush::instance_art(&sym.art, n); + let count = art.count_with_masks() as u64; + let most = doc.symbol_slice_map(symbol, xf).map_or(count, |m| m.most_placed(&art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let placed = doc.place_symbol_art(symbol, &art, xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }); + match placed { + Some(Some(art)) => { + let art = self.baked(art); + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + Some(None) => {} + None => { + self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES; + self.warn(SYMBOLS_LEFT_OUT); + } + } + } + + /// Placed symbol art with its geometry effects applied: the document's bake reaches the + /// layers, not the symbols' art. + fn baked(&mut self, art: Node) -> Node { + if !vectorcraft_effects::needs_bake(&art) { + return art; + } + // Guides and layers left out go first, as for the document's layers. + let art = Arc::new(art); + let art = match plotted(std::slice::from_ref(&art), false) { + Some(mut kept) => match kept.pop() { + Some(a) => a, + None => return Node::group(vectorcraft_doc::NodeId(0), vec![]), + }, + None => art, + }; + let art = Arc::unwrap_or_clone(art); + let doc = self.doc; + let scratch = self.scratch.get_or_insert_with(|| { + let mut d = doc.clone(); + d.layers.clear(); + d + }); + scratch.layers = vec![Arc::new(art)]; + let baked = vectorcraft_effects::bake_document(scratch).and_then(|b| b.layers.into_iter().next()); + let art = std::mem::take(&mut scratch.layers).into_iter().next(); + baked.or(art).map(Arc::unwrap_or_clone).unwrap_or_else(|| Node::group(vectorcraft_doc::NodeId(0), vec![])) + } + + /// A container's children. A clipping container's first child cuts the rest; it is drawn + /// itself, once and whole, when it paints: where its stroke paints (over the clipped art) when + /// it has one, else where its fill paints (below it). Nothing to clip by hides the clipped art, + /// as on the canvas. + fn children(&mut self, children: &[Arc<Node>], clip: bool) { + let Some((first, rest)) = children.split_first().filter(|_| clip) else { + children.iter().for_each(|c| self.node(c, false)); + return; + }; + let mut paint = first.clip_paint(); + // A stroke at 0% opacity isn't drawn, so the clipping path is drawn in its fill. + let type_stroke = |s: &Node| matches!(&s.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.has_stroke())); + paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s)); + if paint.stroke.is_none() + && let Some(fill) = &paint.fill + { + self.node(fill, false); + } + match vectorcraft_effects::clip_outline(first).map(|(bp, rule)| (self.rings(&bp), rule)) { + Some((Some(rings), rule)) => { + let held: usize = rings.iter().map(Vec::len).sum(); + if let Some(region) = Region::new(rings, rule) { + self.mask_points += held; + self.clips.push(region); + rest.iter().for_each(|c| self.node(c, false)); + self.clips.pop(); + self.mask_points -= held; + } + } + // A mask past the budget isn't built: the art it would clip is drawn unclipped by it. + Some((None, _)) => { + self.warn(MASK_POINTS); + rest.iter().for_each(|c| self.node(c, false)); + } + None => {} + } + // Drawn whole where its stroke paints: a fill closes its outline, and type's characters + // without a stroke are drawn too. + if paint.stroke.is_some() { + self.node(first, false); + } + } + + /// `bp` flattened into closed rings for a clipping mask; `None` when they would take the + /// masks around it past [`MAX_MASK_POINTS`]. + fn rings(&self, bp: &BezPath) -> Option<Vec<Vec<Point>>> { + let left = MAX_MASK_POINTS.saturating_sub(self.mask_points); + let (mut rings, whole) = self.flatten_within(bp, true, left); + if !whole || rings.iter().map(Vec::len).sum::<usize>() > left { + return None; + } + for r in &mut rings { + r.pop(); + } + Some(rings) + } + + /// A path drawn in its color: the topmost stroke's that paints, else the topmost fill's that + /// paints (visible, with a paint and some opacity). A filled path's every subpath is closed, + /// as its fill closes it, also when its stroke gives the color. + fn shape(&mut self, n: &Node, bp: &BezPath) { + if bp.elements().is_empty() { + return; + } + let fill = top_fill(n); + let paint = match (top_stroke(n), fill) { + (Some(s), _) => { + if s.dash.as_ref().is_some_and(|d| d.is_dashed()) + || s.brush.is_some() + || s.profile.is_some() + || s.start_arrow.is_some() + || s.end_arrow.is_some() + { + self.warn(STROKE_LOOK); + } + &s.paint + } + (None, Some(f)) => f, + (None, None) => return, + }; + let rgb = self.flat(paint); + self.draw(rgb, bp, fill.is_some()); + } + + /// `bp` drawn in `rgb`: flattened (with `close` every subpath closed), then cut and added. + fn draw(&mut self, rgb: [u8; 3], bp: &BezPath, close: bool) { + // Bounded by the plot's point budget; each curve by its own segment cap. + let budget = MAX_POINTS.saturating_sub(self.points).saturating_mul(4).max(4); + let (lines, whole) = self.flatten_within(bp, close, budget); + if !whole { + self.warn(TOO_MANY_POINTS); + } + for line in lines { + self.add(rgb, line); + } + } + + /// One color for a paint: a solid color, the average of a gradient's stops, or a pattern's + /// first solid fill (gray without one). + fn flat(&self, p: &Paint) -> [u8; 3] { + match p { + Paint::None => [0; 3], + Paint::Solid { color, .. } => rgb8(color), + Paint::Gradient(g) => average(g.gradient.stops.iter().map(|s| rgb8(&s.color))), + Paint::Pattern { pattern, .. } => { + self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a))).map_or([128; 3], |c| rgb8(&c)) + } + } + } + + /// `bp` as polylines (each subpath one), within the tolerance, stopping once about `budget` + /// moves are made; with `close` every subpath ends at its start, else those that close. Also + /// whether every element of `bp` was flattened. + fn flatten_within(&self, bp: &BezPath, close: bool, budget: usize) -> (Vec<Vec<Point>>, bool) { + let mut out: Vec<Vec<Point>> = vec![]; + let mut cur: Vec<Point> = vec![]; + let end = |cur: &mut Vec<Point>, out: &mut Vec<Vec<Point>>, closing: bool| { + if (closing || close) + && let Some(&s) = cur.first() + && cur.last() != Some(&s) + { + cur.push(s); + } + out.push(std::mem::take(cur)); + }; + let (made, taken) = (std::cell::Cell::new(0usize), std::cell::Cell::new(0usize)); + let elements = bp.iter().take_while(|_| made.get() < budget).inspect(|_| taken.set(taken.get() + 1)); + vectorcraft_geom::flatten(elements, self.tolerance, |el| { + made.set(made.get() + 1); + match el { + PathEl::MoveTo(p) => { + end(&mut cur, &mut out, false); + cur.push(p); + } + PathEl::LineTo(p) => { + if cur.last() != Some(&p) { + cur.push(p); + } + } + PathEl::ClosePath => { + let s = cur.first().copied(); + end(&mut cur, &mut out, true); + // A subpath that goes on after its close starts where it closed. + if let Some(s) = s { + cur.push(s); + } + } + _ => {} + } + }); + let whole = taken.get() == bp.elements().len(); + // A subpath the budget cut short stays open: a move back to its start isn't on its outline. + if whole { + end(&mut cur, &mut out, false); + } else { + out.push(cur); + } + out.retain(|l| l.len() >= 2); + (out, whole) + } + + /// A polyline drawn in `rgb`, cut by the region and the clipping masks around it. + fn add(&mut self, rgb: [u8; 3], line: Vec<Point>) { + // Only the part over the region asked for counts: the rest is cut off anyway. + if let (Some((range, _)), Some(asked)) = (self.range, &self.asked) + && !self.warnings.iter().any(|w| w == OUT_OF_RANGE) + { + let mut unlimited = u64::MAX; + if asked.cut(&line, &mut unlimited).unwrap_or_default().iter().flatten().any(|p| !holds(range, *p)) { + self.warn(OUT_OF_RANGE); + } + } + // The region has four edges, so its cut costs a few tests a point and needs no limit. + let mut unlimited = u64::MAX; + let mut pieces = self.edge.as_ref().and_then(|edge| edge.cut(&line, &mut unlimited)).unwrap_or_default(); + for clip in &self.clips { + let mut left = MAX_CLIP_TESTS.saturating_sub(self.clip_tests); + let cut: Option<Vec<Vec<Vec<Point>>>> = pieces.iter().map(|p| clip.cut(p, &mut left)).collect(); + self.clip_tests = MAX_CLIP_TESTS.saturating_sub(left); + match cut { + Some(cut) => pieces = cut.into_iter().flatten().collect(), + None => { + // Spent: this art keeps the parts this mask and the ones inside it would cut, + // and later art that reaches a mask isn't clipped either. + self.clip_tests = MAX_CLIP_TESTS; + warn(&mut self.warnings, CLIP_WORK); + break; + } + } + } + for mut points in pieces { + let left = MAX_POINTS.saturating_sub(self.points); + if points.len() > left { + self.warn(TOO_MANY_POINTS); + points.truncate(left); + } + if points.len() < 2 { + return; + } + self.points += points.len(); + self.runs.push(Run { rgb, points }); + } + } +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// `nodes` (the children of a clipping container when `clipping`) without the layers and +/// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without +/// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn +/// (its Crop Marks); `None` when nothing changes. A guide that is a clipping path keeps its place +/// without its effects. +fn plotted(nodes: &[Arc<Node>], clipping: bool) -> Option<Vec<Arc<Node>>> { + let mut changed = false; + let mut out = Vec::with_capacity(nodes.len()); + for (i, n) in nodes.iter().enumerate() { + match &n.kind { + NodeKind::Layer { printable, template, .. } if !(n.visible && n.opacity > 0.0 && *printable && !*template) => { + changed = true; + continue; + } + NodeKind::Path { guide: true, .. } => { + changed = true; + if clipping && i == 0 { + let mut g = (**n).clone(); + g.appearance.effects.clear(); + out.push(Arc::new(g)); + } + continue; + } + _ => {} + } + let inner = match &n.kind { + NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } => plotted(children, *clip), + _ => None, + }; + match inner { + Some(children) => { + changed = true; + let mut c = (**n).clone(); + if let Some(ch) = c.children_mut() { + *ch = children; + } + out.push(Arc::new(c)); + } + None => out.push(n.clone()), + } + } + changed.then_some(out) +} + +/// The symbols of `doc` whose art holds geometry effects, and those whose art holds instances of +/// them, as deep as symbols are drawn ([`MAX_NEST`]). +fn effect_symbols(doc: &Document) -> HashSet<String> { + let mut set: HashSet<String> = doc.symbols.iter().filter(|s| vectorcraft_effects::needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + for _ in 0..MAX_NEST { + if set.is_empty() { + break; + } + let more: Vec<String> = + doc.symbols.iter().filter(|s| !set.contains(&s.name) && holds_instance_of(&s.art, &set)).map(|s| s.name.clone()).collect(); + if more.is_empty() { + break; + } + set.extend(more); + } + set +} + +/// Is `n`, or anything in it, an instance of one of `symbols`? +fn holds_instance_of(n: &Node, symbols: &HashSet<String>) -> bool { + match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.contains(symbol), + _ => n.children().into_iter().flatten().any(|c| holds_instance_of(c, symbols)), + } +} + +fn rgb8(c: &vectorcraft_color::Color) -> [u8; 3] { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] +} + +/// The average of `colors` (black without any). +fn average(colors: impl Iterator<Item = [u8; 3]>) -> [u8; 3] { + let (sum, n) = colors.fold(([0.0; 3], 0.0), |(a, n), c| ([0, 1, 2].map(|i| a[i] + f64::from(c[i])), n + 1.0)); + sum.map(|v| (v / f64::max(n, 1.0)).round().clamp(0.0, 255.0) as u8) +} + +/// The first visible solid fill color in `n` (a pattern tile's art), hidden objects and objects +/// or fills at 0% opacity left out. +fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> { + if !n.visible || n.opacity <= 0.0 { + return None; + } + let own = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 => f.paint.color(), + _ => None, + }); + own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) +} + +/// The topmost stroke of `n` that paints: visible, with a paint, a weight and some opacity. +fn top_stroke(n: &Node) -> Option<&StrokeLayer> { + n.appearance.items.iter().rev().find_map(|i| match i { + AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => Some(s), + _ => None, + }) +} + +/// The paint of the topmost fill of `n` that paints: visible, with a paint and some opacity. +fn top_fill(n: &Node) -> Option<&Paint> { + n.appearance.items.iter().rev().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0 => Some(&f.paint), + _ => None, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/hpgl/write/clip.rs b/crates/cad/src/hpgl/write/clip.rs new file mode 100644 index 0000000000..235032ee2b --- /dev/null +++ b/crates/cad/src/hpgl/write/clip.rs @@ -0,0 +1,265 @@ +//! Polylines cut by a region: each segment is split where it crosses the region's edges, and the +//! pieces whose middle lies inside are kept, joined where they meet. + +use vectorcraft_geom::{FillRule, Point, Rect}; + +/// A clipping region: closed rings (each closes implicitly) and the rule that decides inside. +pub(super) struct Region { + rings: Vec<Vec<Point>>, + rule: FillRule, + bounds: Rect, +} + +impl Region { + /// The region of `rings` under `rule`; `None` when it has no ring of three points or more. + pub fn new(rings: Vec<Vec<Point>>, rule: FillRule) -> Option<Self> { + let rings: Vec<Vec<Point>> = rings.into_iter().filter(|r| r.len() >= 3).collect(); + let bounds = rings.iter().flatten().fold(None, |b: Option<Rect>, p| Some(b.map_or(Rect::from_points(*p, *p), |b| b.union_pt(*p))))?; + Some(Self { rings, rule, bounds }) + } + + /// The edges this region has, which [`Region::cut`] tests each segment against. + fn edges(&self) -> u64 { + self.rings.iter().map(|r| r.len() as u64).sum() + } + + /// Does `p` lie on one of the region's edges (within a millionth of a point)? + fn on_edge(&self, p: Point) -> bool { + self.rings.iter().any(|ring| { + ring.iter().enumerate().any(|(i, &a)| { + let b = ring[(i + 1) % ring.len()]; + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(p) <= 1e-6 + }) + }) + } + + /// Is `p` inside (non-zero winding or an odd number of crossings), or on an edge? + fn contains(&self, p: Point) -> bool { + if self.on_edge(p) { + return true; + } + let mut winding = 0i64; + for ring in &self.rings { + for (i, &a) in ring.iter().enumerate() { + let b = ring[(i + 1) % ring.len()]; + if a.y <= p.y { + if b.y > p.y && cross(a, b, p) > 0.0 { + winding += 1; + } + } else if b.y <= p.y && cross(a, b, p) < 0.0 { + winding -= 1; + } + } + } + match self.rule { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + } + } + + /// Where segment `a`–`b` crosses this region's edges, as fractions of its length strictly + /// between 0 and 1, sorted. + fn crossings(&self, a: Point, b: Point) -> Vec<f64> { + let mut ts = vec![]; + for ring in &self.rings { + for (i, &c) in ring.iter().enumerate() { + let d = ring[(i + 1) % ring.len()]; + if let Some(t) = intersect(a, b, c, d) + && t > 1e-12 + && t < 1.0 - 1e-12 + { + ts.push(t); + } + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12); + ts + } + + /// The parts of polyline `line` inside the region, each a polyline of two points or more. + /// Every test of a point or a segment against an edge takes one from `budget`; `None` when + /// the budget runs out first. + pub fn cut(&self, line: &[Point], budget: &mut u64) -> Option<Vec<Vec<Point>>> { + let edges = self.edges(); + let mut out: Vec<Vec<Point>> = vec![]; + let mut cur: Vec<Point> = vec![]; + for w in line.windows(2) { + let (a, b) = (w[0], w[1]); + // A segment wholly off the region's box lies outside it. + let near = overlaps(Rect::from_points(a, b), self.bounds); + let ts = if near { + spend(budget, edges)?; + self.crossings(a, b) + } else { + vec![] + }; + let mut from = 0.0; + for to in ts.into_iter().chain([1.0]) { + let (p, q) = (a.lerp(b, from), a.lerp(b, to)); + // Each piece between crossings is tested on its own: its edge and winding tests. + let inside = if near { + spend(budget, edges.saturating_mul(2))?; + self.contains(p.midpoint(q)) + } else { + false + }; + if inside { + if cur.last().is_none_or(|l| l.distance(p) > 1e-9) { + flush(&mut out, &mut cur); + cur.push(p); + } + cur.push(q); + } else { + flush(&mut out, &mut cur); + } + from = to; + } + } + flush(&mut out, &mut cur); + // A closed line that leaves the region and comes back: its last piece ends where its + // first starts, at the line's start, and the two are one piece. + if line.len() > 2 + && line.first() == line.last() + && out.len() >= 2 + && let (Some(end), Some(start)) = (out.last().and_then(|l| l.last()), out.first().and_then(|l| l.first())) + && end.distance(*start) <= 1e-9 + { + let first = out.remove(0); + if let Some(last) = out.last_mut() { + last.extend(first.into_iter().skip(1)); + } + } + Some(out) + } +} + +/// Take `n` from `budget`; `None` when it holds less. +fn spend(budget: &mut u64, n: u64) -> Option<()> { + *budget = budget.checked_sub(n)?; + Some(()) +} + +fn flush(out: &mut Vec<Vec<Point>>, cur: &mut Vec<Point>) { + let line = std::mem::take(cur); + if line.len() >= 2 { + out.push(line); + } +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Which side of line `a`–`b` point `p` lies on (positive: left). +fn cross(a: Point, b: Point, p: Point) -> f64 { + (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y) +} + +/// Where segment `a`–`b` crosses segment `c`–`d`, as a fraction of `a`–`b`; `None` when they +/// are parallel or don't meet. +fn intersect(a: Point, b: Point, c: Point, d: Point) -> Option<f64> { + let (r, s) = (b - a, d - c); + let den = r.x * s.y - r.y * s.x; + if den.abs() <= f64::EPSILON * (r.hypot() * s.hypot()).max(f64::MIN_POSITIVE) { + return None; + } + let ac = c - a; + let t = (ac.x * s.y - ac.y * s.x) / den; + let u = (ac.x * r.y - ac.y * r.x) / den; + ((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)).then_some(t) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<Point> { + vec![Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)] + } + + /// `line` cut by `r` with no limit on the work. + fn cut(r: &Region, line: &[Point]) -> Vec<Vec<Point>> { + let mut unlimited = u64::MAX; + r.cut(line, &mut unlimited).unwrap() + } + + #[test] + fn a_line_across_a_square_keeps_its_middle() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let pieces = cut(&r, &[Point::new(-5.0, 5.0), Point::new(15.0, 5.0)]); + assert_eq!(pieces, vec![vec![Point::new(0.0, 5.0), Point::new(10.0, 5.0)]]); + // Wholly outside, nothing; wholly inside, the line as it is. + assert!(cut(&r, &[Point::new(20.0, 0.0), Point::new(30.0, 0.0)]).is_empty()); + let inside = vec![Point::new(1.0, 1.0), Point::new(9.0, 1.0), Point::new(9.0, 9.0)]; + assert_eq!(cut(&r, &inside), vec![inside]); + } + + /// Each piece between crossings costs a pass over the edges: a line through the teeth of a + /// comb spends far more than its two points times the edges. + #[test] + fn every_piece_between_crossings_is_paid_for() { + let mut ring = vec![]; + for i in 0..100 { + let x = f64::from(i) * 4.0; + ring.extend([Point::new(x, 0.0), Point::new(x + 2.0, 0.0), Point::new(x + 2.0, 10.0), Point::new(x + 4.0, 10.0)]); + } + ring.extend([Point::new(400.0, 20.0), Point::new(0.0, 20.0)]); + let r = Region::new(vec![ring], FillRule::NonZero).unwrap(); + let line = [Point::new(-1.0, 5.0), Point::new(401.0, 5.0)]; + // 402 edges, 200 crossings, 201 pieces: 402 + 201 × 2 × 402 tests. + let need = 402 + 201 * 2 * 402; + let mut budget = need; + assert_eq!(r.cut(&line, &mut budget).map(|p| p.len()), Some(100)); + assert_eq!(budget, 0); + let mut short = need - 1; + assert!(r.cut(&line, &mut short).is_none()); + // A segment off the region's box costs nothing. + assert_eq!(r.cut(&[Point::new(0.0, 30.0), Point::new(9.0, 30.0)], &mut 0), Some(vec![])); + } + + /// A line along any of the region's edges is inside it. + #[test] + fn a_line_on_an_edge_is_kept() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + for line in [ + [Point::new(0.0, 0.0), Point::new(10.0, 0.0)], + [Point::new(10.0, 0.0), Point::new(10.0, 10.0)], + [Point::new(10.0, 10.0), Point::new(0.0, 10.0)], + [Point::new(0.0, 10.0), Point::new(0.0, 0.0)], + ] { + assert_eq!(cut(&r, &line), vec![line.to_vec()], "{line:?}"); + } + } + + #[test] + fn a_line_leaving_and_coming_back_is_two_pieces() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0)]; + assert_eq!(cut(&r, &line), vec![vec![Point::new(5.0, 5.0), Point::new(10.0, 5.0)], vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0)]]); + } + + /// A closed line that starts inside, leaves and comes back is one piece: the part after it + /// comes back runs on into the part before it left. + #[test] + fn a_closed_line_cut_once_stays_one_piece() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0)]; + assert_eq!(cut(&r, &line), vec![vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0), Point::new(10.0, 5.0)]]); + } + + #[test] + fn the_fill_rule_decides_a_hole() { + let rings = vec![square(0.0, 0.0, 10.0, 10.0), square(3.0, 3.0, 7.0, 7.0)]; + let line = [Point::new(-1.0, 5.0), Point::new(11.0, 5.0)]; + // Both rings wind the same way: even-odd cuts a hole, non-zero doesn't. + let even_odd = cut(&Region::new(rings.clone(), FillRule::EvenOdd).unwrap(), &line); + assert_eq!(even_odd, vec![vec![Point::new(0.0, 5.0), Point::new(3.0, 5.0)], vec![Point::new(7.0, 5.0), Point::new(10.0, 5.0)]]); + let non_zero = cut(&Region::new(rings, FillRule::NonZero).unwrap(), &line); + assert_eq!(non_zero.len(), 1); + assert!(Region::new(vec![vec![Point::ZERO, Point::new(1.0, 1.0)]], FillRule::NonZero).is_none()); + } +} diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs new file mode 100644 index 0000000000..2093fe8b48 --- /dev/null +++ b/crates/cad/src/hpgl/write/tests.rs @@ -0,0 +1,936 @@ +//! HP-GL export: the instructions written, pens, origins, what is cut and left out, limits, and +//! plots read back by the HP-GL reader. + +use std::sync::Arc; + +use kurbo::{PathEl, Shape as _}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, LayerColor, Node, NodeKind, TextObject}; +use vectorcraft_geom::{PathData, Point, Rect, shapes}; + +use super::*; + +/// A 200 × 100 pt document whose first layer holds what `nodes` makes. +fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document { + let mut d = Document::new(200.0, 100.0); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +fn stroked(d: &mut Document, p: PathData, c: Color) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::solid(c), 1.0)) +} + +fn filled(d: &mut Document, p: PathData, c: Color) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(Paint::solid(c), Paint::None, 1.0)) +} + +fn line(a: (f64, f64), b: (f64, f64)) -> PathData { + let mut bp = BezPath::new(); + bp.move_to(a); + bp.line_to(b); + PathData::from_bezpath(&bp) +} + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData { + shapes::rectangle(Rect::new(x0, y0, x1, y1)) +} + +/// The options with the first artboard as the region. +fn opts(d: &Document) -> HpglOptions { + HpglOptions { region: d.artboards[0].rect, ..HpglOptions::default() } +} + +fn plot(d: &Document, o: &HpglOptions) -> HpglOutput { + match export(d, o) { + Ok(out) => out, + Err(e) => panic!("{e}"), + } +} + +fn text(out: &HpglOutput) -> String { + String::from_utf8(out.bytes.clone()).expect("ASCII") +} + +/// A black square outline, a red line and a blue ellipse filled without a stroke. +fn three() -> Document { + doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK), + stroked(d, line((70.0, 20.0), (150.0, 80.0)), Color::rgb8(255, 0, 0)), + filled(d, shapes::ellipse(Rect::new(70.0, 30.0, 130.0, 70.0)), Color::rgb8(0, 0, 255)), + ] + }) +} + +/// Every instruction in the HP-GL reference guide's form: a two-letter mnemonic, integer +/// parameters separated by commas, and a semicolon (https://support.hp.com/sg-en/document/c01121263). +#[test] +fn a_plot_is_pen_moves_in_plotter_units_from_the_bottom_left() { + let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let out = plot(&d, &opts(&d)); + // 1016 units to the inch, y up from the artboard's bottom edge; the square closes. + assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD847,1270,847,847,141,847,141,1270;PU;SP0;"); + for i in text(&out).split_terminator(';') { + let (op, params) = i.split_at(2); + assert!(op.bytes().all(|c| c.is_ascii_uppercase()), "{i}"); + assert!(params.is_empty() || params.split(',').all(|v| v.parse::<i64>().is_ok()), "{i}"); + } + assert_eq!(out.pens, [[0, 0, 0]]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn the_origin_can_be_the_center() { + let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let out = plot(&d, &HpglOptions { origin: Origin::Center, ..opts(&d) }); + // The artboard's center is (100, 50): x from -1270, y from 564 (40 pt up). + assert_eq!(text(&out), "IN;PU;SP1;PU-1270,564;PD-564,564,-564,141,-1270,141,-1270,564;PU;SP0;"); +} + +#[test] +fn each_new_color_is_the_next_pen_and_pens_draw_in_turn() { + let d = doc_with(|d| { + vec![ + stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::rgb8(255, 0, 0)), + stroked(d, line((10.0, 20.0), (20.0, 20.0)), Color::BLACK), + stroked(d, line((10.0, 30.0), (20.0, 30.0)), Color::rgb8(255, 0, 0)), + ] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[255, 0, 0], [0, 0, 0]]); + assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD282,1270;PU141,988;PD282,988;PU;SP2;PU141,1129;PD282,1129;PU;SP0;"); + // A pen table: each color takes the nearest pen's (red is as near black as blue, and black + // comes first). + let table = plot(&d, &HpglOptions { pens: vec![[0, 0, 0], [0, 0, 255]], ..opts(&d) }); + assert_eq!(table.pens, [[0, 0, 0], [0, 0, 255]]); + assert!(text(&table).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&table)); + assert!(!text(&table).contains("SP2"), "{}", text(&table)); +} + +#[test] +fn a_filled_path_is_its_closed_outline_and_a_stroke_its_line() { + let d = doc_with(|d| { + let mut open = BezPath::new(); + open.move_to((10.0, 10.0)); + open.line_to((20.0, 10.0)); + open.line_to((20.0, 20.0)); + let open = PathData::from_bezpath(&open); + let both = Node::path(d.alloc_id(), open.clone(), Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb8(255, 0, 0)), 1.0)); + vec![filled(d, open.clone(), Color::BLACK), stroked(d, open, Color::BLACK), both] + }); + let out = plot(&d, &opts(&d)); + let t = text(&out); + assert!(t.contains("PU141,1270;PD282,1270,282,1129,141,1270;"), "the fill closes: {t}"); + assert!(t.contains("PU141,1270;PD282,1270,282,1129;PU;"), "the stroke doesn't: {t}"); + // A path with a fill and a stroke is closed too, in its stroke's color. + assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]]); + assert!(t.ends_with("SP2;PU141,1270;PD282,1270,282,1129,141,1270;PU;SP0;"), "{t}"); +} + +/// A gradient mesh is its outline, once, in the average of its points' colors. +#[test] +fn a_gradient_mesh_is_its_outline() { + let d = doc_with(|d| { + let mesh = vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(10.0 + u * 50.0, 10.0 + v * 50.0), &|u, _| { + if u < 0.5 { Color::rgb8(255, 0, 0) } else { Color::rgb8(0, 0, 255) } + }); + vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh))] + }); + let out = plot(&d, &opts(&d)); + let t = text(&out); + assert_eq!(t.matches("PD").count(), 1, "{t}"); + assert!(t.starts_with("IN;PU;SP1;PU"), "{t}"); + // The square's four corners are on the line it draws. + for corner in ["141,1270", "847,1270", "847,564", "141,564"] { + assert!(t.contains(corner), "{corner} in {t}"); + } + let [r, g, b] = out.pens[0]; + assert!(r > 0 && b > 0 && g == 0, "{:?}", out.pens); +} + +/// A square gradient mesh, 30 pt a side, at `x`. +fn mesh_at(x: f64) -> vectorcraft_doc::live::GradientMesh { + vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(x + u * 30.0, 10.0 + v * 30.0), &|_, _| Color::BLACK) +} + +fn blend_of(d: &mut Document, keys: Vec<Node>, steps: u32) -> Node { + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Steps(steps), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) }) +} + +/// A mesh inside a blend is its outline too: a blend of two meshes with one step between them +/// draws three outlines. A mesh whose every point is at 0% opacity draws nothing. +#[test] +fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() { + let d = doc_with(|d| { + let keys = vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(110.0)))]; + let mut clear = mesh_at(60.0); + clear.points.iter_mut().for_each(|p| p.opacity = 0.0); + vec![blend_of(d, keys, 1), Node::new(d.alloc_id(), NodeKind::Mesh(clear))] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t.matches("PD").count(), 3, "{t}"); +} + +/// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a +/// warning. +#[test] +fn live_art_past_its_limit_is_left_out() { + let d = doc_with(|d| { + let keys = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((190.0, 10.0), (190.0, 20.0)), Color::BLACK)]; + vec![blend_of(d, keys, 1_200)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == LIVE_LEFT_OUT), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// A line shorter than a plotter unit draws nothing and takes no pen. +#[test] +fn a_line_that_draws_nothing_takes_no_pen() { + let d = doc_with(|d| { + vec![stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::rgb8(255, 0, 0)), stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[0, 0, 0]]); + assert!(text(&out).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&out)); + let two = plot(&d, &HpglOptions { version: Version::Hpgl2, ..opts(&d) }); + assert!(two.bytes.starts_with(b"IN;NP2;PC1,0,0,0;PU;SP1;PE"), "{:?}", String::from_utf8_lossy(&two.bytes)); +} + +/// The plots of one export share the pens: a color is the same pen in each, numbered in the +/// order the plots first draw in it. +#[test] +fn the_plots_of_one_export_share_their_pens() { + let d = doc_with(|d| { + vec![ + stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK), + stroked(d, line((110.0, 10.0), (120.0, 10.0)), Color::rgb8(255, 0, 0)), + stroked(d, line((110.0, 20.0), (120.0, 20.0)), Color::BLACK), + ] + }); + let regions = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)]; + let plots = export_regions(&d, &HpglOptions::default(), ®ions).unwrap(); + assert_eq!(plots.len(), 2); + for p in &plots { + assert_eq!(p.pens, [[0, 0, 0], [255, 0, 0]]); + } + assert_eq!(text(&plots[0]), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + assert_eq!(text(&plots[1]), "IN;PU;SP1;PU141,1129;PD282,1129;PU;SP2;PU141,1270;PD282,1270;PU;SP0;"); +} + +#[test] +fn hidden_and_non_printing_art_is_left_out() { + let mut d = doc_with(|d| { + let mut hidden = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK); + hidden.visible = false; + let none = Node::path(d.alloc_id(), rect(30.0, 10.0, 40.0, 20.0), Appearance::basic(Paint::None, Paint::None, 1.0)); + vec![hidden, none] + }); + let mut layer = Node::layer(d.alloc_id(), "Notes", LayerColor::Preset(1)); + if let NodeKind::Layer { printable, .. } = &mut layer.kind { + *printable = false; + } + let art = stroked(&mut d, rect(50.0, 10.0, 60.0, 20.0), Color::BLACK); + layer.children_mut().unwrap().push(Arc::new(art)); + d.layers.push(Arc::new(layer)); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + +/// Hidden and template layers, guides of either kind and placed images draw nothing; an image +/// comes back as a warning. +#[test] +fn layers_guides_and_images_that_draw_nothing_are_left_out() { + let mut d = doc_with(|d| { + let mut guide = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK); + if let NodeKind::Path { guide, .. } = &mut guide.kind { + *guide = true; + } + let ruler = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 50.0, artboard: None }); + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ruler, guide, Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))] + }); + let hidden = d.add_layer(Some("Notes")); + let template = d.add_layer(Some("Tracing")); + for l in [hidden, template] { + let n = stroked(&mut d, rect(30.0, 10.0, 40.0, 20.0), Color::BLACK); + d.insert(Some(l), 0, n).unwrap(); + } + if let Some(n) = d.node_mut(hidden) { + n.visible = false; + } + if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) { + *template = true; + } + let out = plot(&d, &opts(&d)); + assert_eq!(text(&out), "IN;PU;SP0;"); + assert_eq!(out.warnings, [IMAGES]); +} + +/// A non-printing layer's Crop Marks are left out with the layer, and so is a template layer's. +#[test] +fn the_crop_marks_of_a_layer_left_out_are_left_out() { + for template in [false, true] { + let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let layer = d.layers[0].id; + if let Some(n) = d.node_mut(layer) { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + if let NodeKind::Layer { printable, template: t, .. } = &mut n.kind { + (*printable, *t) = (template, template); + } + } + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;", "template: {template}"); + } +} + +/// A guide's Crop Marks are left out with the guide, in the layers and in a symbol's art. +#[test] +fn the_crop_marks_of_a_guide_are_left_out() { + let guide = |d: &mut Document| { + let mut g = stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + g.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + if let NodeKind::Path { guide, .. } = &mut g.kind { + *guide = true; + } + g + }; + let d = doc_with(|d| { + let g = guide(d); + vec![Node::group(d.alloc_id(), vec![Arc::new(g)])] + }); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); + let mut d = Document::new(200.0, 100.0); + let g = guide(&mut d); + let art = Node::group(d.alloc_id(), vec![Arc::new(g)]); + let d = symbol_doc(art, |_| {}); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + +/// Inline graphics in type are left out with a warning. +#[test] +fn inline_graphics_in_type_are_left_out_with_a_warning() { + let d = doc_with(|d| { + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let mut t = TextObject::point(Point::new(10.0, 60.0), "O", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("mark"), style)); + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == INLINE_GRAPHICS), "{:?}", out.warnings); +} + +/// A gradient's pen is the average of its stops' colors. +#[test] +fn a_gradient_takes_the_average_of_its_stops() { + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + g.gradient.stops[0].color = Color::rgb8(255, 0, 0); + g.gradient.stops[1].color = Color::rgb8(0, 0, 255); + let d = + doc_with(|d| vec![Node::path(d.alloc_id(), rect(10.0, 10.0, 60.0, 40.0), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 1.0))]); + assert_eq!(plot(&d, &opts(&d)).pens, [[128, 0, 128]]); +} + +/// An object or group at 0% opacity shows nothing, and a fill or stroke at 0% opacity gives a +/// path no color: here only the blue fill's outline is drawn. +#[test] +fn art_at_no_opacity_is_left_out() { + let (red, green, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 255, 0), Color::rgb8(0, 0, 255)); + let d = doc_with(|d| { + let mut faded = stroked(d, rect(10.0, 10.0, 20.0, 20.0), red); + faded.opacity = 0.0; + let inner = stroked(d, line((30.0, 10.0), (40.0, 10.0)), green); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + group.opacity = 0.0; + let mut clear_fill = filled(d, rect(10.0, 30.0, 20.0, 40.0), green); + let mut fill_and_clear_stroke = + Node::path(d.alloc_id(), rect(50.0, 10.0, 60.0, 20.0), Appearance::basic(Paint::solid(blue), Paint::solid(red), 1.0)); + for item in clear_fill.appearance.items.iter_mut().chain(fill_and_clear_stroke.appearance.items.iter_mut()) { + match item { + AppearanceItem::Fill(f) if f.paint == Paint::solid(green) => f.opacity = 0.0, + AppearanceItem::Stroke(s) => s.opacity = 0.0, + _ => {} + } + } + vec![faded, group, clear_fill, fill_and_clear_stroke] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[0, 0, 255]]); + assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out)); +} + +/// A pattern's pen is the color of its first visible solid fill: a hidden tile object, one at 0% +/// opacity and a fill at 0% opacity are skipped. +#[test] +fn a_pattern_takes_the_color_of_its_first_visible_fill() { + let mut d = doc_with(|_| vec![]); + let mut hidden = filled(&mut d, rect(0.0, 0.0, 5.0, 5.0), Color::rgb8(255, 0, 0)); + hidden.visible = false; + let mut faded = filled(&mut d, rect(0.0, 5.0, 5.0, 10.0), Color::rgb8(0, 255, 0)); + faded.opacity = 0.0; + let mut clear_fill = filled(&mut d, rect(5.0, 5.0, 10.0, 10.0), Color::rgb8(255, 255, 0)); + for item in &mut clear_fill.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.opacity = 0.0; + } + } + let shown = filled(&mut d, rect(5.0, 0.0, 10.0, 5.0), Color::rgb8(0, 0, 255)); + let art = [hidden, faded, clear_fill, shown].map(Arc::new).to_vec(); + d.patterns.push(vectorcraft_doc::PatternDef::new("tiles", art)); + let path = Node::path( + d.alloc_id(), + rect(10.0, 10.0, 50.0, 50.0), + Appearance::basic(Paint::Pattern { pattern: "tiles".into(), xf: Default::default() }, Paint::None, 1.0), + ); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, path).unwrap(); + assert_eq!(plot(&d, &opts(&d)).pens, [[0, 0, 255]]); +} + +#[test] +fn art_is_cut_at_the_artboard_and_by_clipping_masks() { + let d = doc_with(|d| { + let across = stroked(d, line((-50.0, 50.0), (250.0, 50.0)), Color::BLACK); + let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::default()); + let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![across, group] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t, "IN;PU;SP1;PU0,706;PD2822,706;PU282,988;PD564,988;PU;SP0;"); +} + +#[test] +fn type_is_drawn_as_its_outlines() { + let d = doc_with(|d| { + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Ol", style))))] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TEXT_OUTLINES), "{:?}", out.warnings); + // A pen-up move to each contour (the O's two and the l's one), and the two around the pen. + let t = text(&out); + assert!(t.matches("PU").count() >= 5, "{t}"); +} + +#[test] +fn an_empty_or_zero_length_plot_has_no_moves() { + let empty = Document::new(200.0, 100.0); + assert_eq!(text(&plot(&empty, &opts(&empty))), "IN;PU;SP0;"); + let d = + doc_with(|d| vec![stroked(d, line((10.0, 10.0), (10.0, 10.0)), Color::BLACK), stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::BLACK)]); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); + let bad = HpglOptions { region: Rect::new(0.0, 0.0, 0.0, 10.0), ..HpglOptions::default() }; + assert_eq!(export(&d, &bad).err().as_deref(), Some("the export region is empty")); + let nan = HpglOptions { region: Rect::new(0.0, 0.0, f64::NAN, 10.0), ..HpglOptions::default() }; + assert!(export(&d, &nan).is_err()); +} + +#[test] +fn art_past_the_plotter_range_is_cut_off() { + let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings); + assert_eq!(text(&out), format!("IN;PU;SP1;PU0,706;PD{MAX_COORD},706;PU;SP0;")); + // Art past the range but off the region asked for would be cut off anyway: no warning. + let d = doc_with(|d| vec![stroked(d, line((-1e9, 50.0), (10.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;"); +} + +#[test] +fn a_plot_stops_at_its_point_budget() { + let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect()); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + assert!(text(&out).matches("PU").count() <= MAX_POINTS / 5 + 3); +} + +/// The points a plain plot moves through. +fn moves(t: &str) -> usize { + t.split_terminator(';') + .filter(|i| i.starts_with("PU") || i.starts_with("PD")) + .map(|i| i[2..].split(',').filter(|v| !v.is_empty()).count() / 2) + .sum() +} + +#[test] +fn one_path_longer_than_the_budget_is_cut_at_it() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((1.0, 1.0)); + for i in 0..MAX_POINTS + 10 { + bp.line_to((if i % 2 == 0 { 2.0 } else { 1.0 }, 1.0 + (i % 50) as f64)); + } + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + let n = moves(&text(&out)); + assert!(n > 2 && n <= MAX_POINTS, "{n}"); +} + +/// A path whose flattening stops at the budget while its moves so far lie off the artboard: the +/// rest, over the artboard, is left out with a warning. +#[test] +fn a_path_cut_short_by_the_budget_warns() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((-50.0, 10.0)); + for i in 0..MAX_POINTS * 4 + 10 { + bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0)); + } + bp.line_to((50.0, 50.0)); + bp.line_to((60.0, 50.0)); + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); +} + +/// A filled path cut short by the budget is not closed: the move from where it stopped back to its +/// start is not on its outline. Only its first move, along y = 50 off the artboard's left edge, +/// is drawn. +#[test] +fn a_filled_path_cut_short_stays_open() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((10.0, 50.0)); + bp.line_to((-40.0, 50.0)); + for i in 0..MAX_POINTS * 4 + 10 { + bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0)); + } + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD0,706;PU;SP0;"); +} + +/// A blend's steps that its bent spine turns onto the region plotted are drawn, although its keys +/// and spine lie off it. +#[test] +fn blend_steps_a_spine_turns_onto_the_region_are_drawn() { + let d = doc_with(|d| { + let keys = + vec![stroked(d, line((-400.0, -200.0), (200.0, -200.0)), Color::BLACK), stroked(d, line((-560.0, -60.0), (40.0, -60.0)), Color::BLACK)]; + let mut spine = BezPath::new(); + spine.move_to((-100.0, -200.0)); + spine.quad_to((-100.0, -60.0), (-260.0, -60.0)); + let spec = vectorcraft_doc::BlendSpec { + spacing: vectorcraft_doc::BlendSpacing::Steps(20), + spine: Some(PathData::from_bezpath(&spine)), + orientation: vectorcraft_doc::BlendOrientation::AlignToPath, + ..Default::default() + }; + vec![Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) })] + }); + // The keys and spine lie above y = -55; the steps turned along the spine cross this area. + let region = Rect::new(-300.0, 0.0, -200.0, 50.0); + let t = text(&plot(&d, &HpglOptions { region, ..HpglOptions::default() })); + assert!(t.contains("PD"), "{t}"); +} + +/// A closed path that starts on the artboard, leaves it and comes back is one line, with no +/// pen lift where it started. +#[test] +fn a_closed_path_cut_at_the_artboard_is_one_line() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((150.0, 10.0)); + bp.line_to((250.0, 10.0)); + bp.line_to((250.0, 50.0)); + bp.line_to((150.0, 50.0)); + bp.close_path(); + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t, "IN;PU;SP1;PU2822,706;PD2117,706,2117,1270,2822,1270;PU;SP0;"); +} + +/// A clipping path that paints is drawn once: where its fill paints (before the art it clips), +/// or where its stroke paints (after it). +#[test] +fn a_clipping_path_is_drawn_where_it_paints() { + let clip_doc = |fill: Paint, stroke: Paint| { + doc_with(|d| { + let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(fill, stroke, 1.0)); + let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let red = Paint::solid(Color::rgb8(255, 0, 0)); + let filled = clip_doc(red.clone(), Paint::None); + assert_eq!(plot(&filled, &opts(&filled)).pens, [[255, 0, 0], [0, 0, 0]]); + let stroked = clip_doc(Paint::None, red.clone()); + assert_eq!(plot(&stroked, &opts(&stroked)).pens, [[0, 0, 0], [255, 0, 0]]); + let both = clip_doc(Paint::solid(Color::rgb8(0, 0, 255)), red); + let out = plot(&both, &opts(&both)); + assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]], "drawn once, in its stroke's color"); + // Two pen changes, a move to each of the two paths, and the last pen up. + assert_eq!(text(&out).matches("PU").count(), 5, "{}", text(&out)); + // A stroke at 0% opacity isn't drawn: the clipping path is drawn in its fill, below. + let faded = doc_with(|d| { + let mut clip = Node::path( + d.alloc_id(), + rect(20.0, 20.0, 40.0, 40.0), + Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0), + ); + for item in &mut clip.appearance.items { + if let AppearanceItem::Stroke(s) = item { + s.opacity = 0.0; + } + } + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]); + // An open clipping path with a fill and a stroke is drawn closed, as other filled paths are. + let mut open = BezPath::new(); + open.move_to((20.0, 20.0)); + open.line_to((40.0, 20.0)); + open.line_to((40.0, 40.0)); + let open = PathData::from_bezpath(&open); + let open_doc = doc_with(|d| { + let clip = Node::path(d.alloc_id(), open, Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0)); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + let t = text(&plot(&open_doc, &opts(&open_doc))); + assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}"); +} + +/// A symbol `art` and an instance of it at its place. +fn symbol_doc(art: Node, instance: impl FnOnce(&mut Node)) -> Document { + let mut d = doc_with(|d| { + let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + instance(&mut n); + vec![n] + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art) }); + d +} + +/// Symbols nested more than 8 deep are left out with a warning. +#[test] +fn symbols_nested_too_deep_are_left_out_with_a_warning() { + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s9".into(), xf: Affine::IDENTITY })]); + let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s0".into(), art: Arc::new(art) }); + for i in 1..10 { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("s{}", i - 1), xf: Affine::IDENTITY }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("s{i}"), art: Arc::new(inner) }); + } + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_DEEP), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// A symbol instance stained by its fill draws in the stained color. +#[test] +fn a_stained_symbol_instance_takes_its_stain() { + let mut d = Document::new(200.0, 100.0); + let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + let d = symbol_doc(art, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 1.0).into()); + assert_eq!(plot(&d, &opts(&d)).pens, [[255, 0, 0]]); +} + +/// The geometry effects in a symbol's art apply: a line moved 30 pt right by a Transform effect. +#[test] +fn a_symbol_draws_its_art_with_its_effects() { + let mut d = Document::new(200.0, 100.0); + let mut art = stroked(&mut d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 30.0}), + visible: true, + }); + let d = symbol_doc(art, |_| {}); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP1;PU564,1270;PD706,1270;PU;SP0;"); +} + +/// Art an effect moves onto the artboard from off it is drawn, for an instance on its own and +/// one in a group: the bounds that skip art off the artboard don't measure symbol effects. +#[test] +fn symbol_art_an_effect_moves_onto_the_artboard_is_drawn() { + let mut d = Document::new(200.0, 100.0); + let mut art = stroked(&mut d, line((-30.0, 10.0), (-20.0, 10.0)), Color::BLACK); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 40.0}), + visible: true, + }); + let alone = symbol_doc(art.clone(), |_| {}); + assert_eq!(text(&plot(&alone, &opts(&alone))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + let mut grouped = doc_with(|d| { + let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + vec![Node::group(d.alloc_id(), vec![Arc::new(n)])] + }); + grouped.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art.clone()) }); + assert_eq!(text(&plot(&grouped, &opts(&grouped))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + // Other art off the artboard is still skipped: a blend there doesn't spend the live art's + // limit (1,000 objects in tests) before the blend on the artboard. + let mut d = doc_with(|d| { + let off = + vec![stroked(d, line((-500.0, 10.0), (-500.0, 20.0)), Color::BLACK), stroked(d, line((-400.0, 10.0), (-400.0, 20.0)), Color::BLACK)]; + let on = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((30.0, 10.0), (30.0, 20.0)), Color::BLACK)]; + vec![blend_of(d, off, 1_200), blend_of(d, on, 1)] + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "unused".into(), art: Arc::new(art) }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 3, "{}", text(&out)); +} + +/// A rectangle with `per_side` straight moves along each side. +fn fine_rect(x0: f64, y0: f64, x1: f64, y1: f64, per_side: u32) -> PathData { + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]; + let mut bp = BezPath::new(); + bp.move_to(corners[0]); + for (k, a) in corners.iter().enumerate() { + let b = corners[(k + 1) % 4]; + for i in 1..=per_side { + let t = f64::from(i) / f64::from(per_side); + bp.line_to((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); + } + } + bp.close_path(); + PathData::from_bezpath(&bp) +} + +/// Nested clipping masks of 6,000 points each pass the 10,000 points masks may hold together +/// in tests: the inner one is left out with a warning, and the line is cut by the outer one only. +#[test] +fn masks_past_their_point_budget_are_left_out() { + let d = doc_with(|d| { + let clip_group = |d: &mut Document, mask: PathData, art: Node| { + let mask = Node::path(d.alloc_id(), mask, Appearance::basic(Paint::None, Paint::None, 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + group + }; + let across = Node::path(d.alloc_id(), line((0.0, 50.0), (200.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let inner = clip_group(d, fine_rect(50.0, 30.0, 150.0, 70.0, 1500), across); + vec![clip_group(d, fine_rect(10.0, 10.0, 190.0, 90.0, 1500), inner)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == MASK_POINTS), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD2681,706;PU;SP0;"); +} + +/// A line through the 500 teeth of a comb-shaped mask crosses its edges about 1,000 times, and +/// each piece between crossings is tested against all 2,002 edges: that spends the clipping +/// budget, so the line is drawn whole with a warning. +#[test] +fn a_mask_crossed_many_times_spends_the_clipping_budget() { + let d = doc_with(|d| { + let mut pts = vec![]; + for i in 0..500 { + let x = f64::from(i) * 0.4; + pts.extend([(x, 20.0), (x + 0.2, 20.0), (x + 0.2, 50.0), (x + 0.4, 50.0)]); + } + pts.extend([(200.0, 80.0), (0.0, 80.0)]); + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + pts[1..].iter().for_each(|&p| bp.line_to(p)); + bp.close_path(); + let comb = Node::path(d.alloc_id(), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::None, 1.0)); + let clipped = stroked(d, line((0.5, 35.0), (199.5, 35.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(comb), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == CLIP_WORK), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out)); +} + +#[test] +fn more_colors_than_pens_share_the_nearest_pen() { + let d = doc_with(|d| (0..300).map(|i| stroked(d, rect(1.0, 1.0, 5.0, 5.0), Color::rgb8((i % 256) as u8, (i / 256) as u8, 7))).collect()); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens.len(), MAX_PENS); + assert!(out.warnings.iter().any(|w| w == MANY_COLORS), "{:?}", out.warnings); +} + +#[test] +fn sorting_draws_the_nearest_path_next() { + let d = doc_with(|d| { + vec![ + stroked(d, line((150.0, 90.0), (150.0, 80.0)), Color::BLACK), + stroked(d, line((10.0, 80.0), (10.0, 90.0)), Color::BLACK), + stroked(d, line((80.0, 90.0), (80.0, 80.0)), Color::BLACK), + ] + }); + let unsorted = text(&plot(&d, &opts(&d))); + assert_eq!(unsorted, "IN;PU;SP1;PU2117,141;PD2117,282;PU141,282;PD141,141;PU1129,141;PD1129,282;PU;SP0;"); + // From the origin: the left line drawn backwards from its lower end, then the middle and the + // right one from the end the pen is nearest. + let sorted = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })); + assert_eq!(sorted, "IN;PU;SP1;PU141,141;PD141,282;PU1129,282;PD1129,141;PU2117,141;PD2117,282;PU;SP0;"); +} + +/// A closed path is as near as its nearest point, and starts there. +#[test] +fn sorting_measures_a_closed_path_by_its_nearest_point() { + let d = doc_with(|d| vec![stroked(d, rect(40.0, 60.0, 50.0, 70.0), Color::BLACK), stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK)]); + let t = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })); + // The large square's bottom-left corner is nearer the origin than any point of the small one. + assert_eq!(t, "IN;PU;SP1;PU141,141;PD141,1270,2681,1270,2681,141,141,141;PU564,423;PD564,564,706,564,706,423,564,423;PU;SP0;"); +} + +#[test] +fn sorting_stops_at_its_budget_and_keeps_every_path() { + let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); + assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 600); +} + +#[test] +fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() { + let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// The reference guide's example of an encoded number: 10,525 is written as the bytes 121, 71 +/// and 196 (https://support.hp.com/sg-en/document/c01121263). +#[test] +fn encoded_numbers_follow_the_reference_guide() { + let mut out = vec![]; + pe_number(&mut out, 10_525); + assert_eq!(out, [121, 71, 196]); + out.clear(); + pe_number(&mut out, -1); + pe_number(&mut out, 0); + // The sign in the low bit: -1 is 3. + assert_eq!(out, [194, 191]); +} + +#[test] +fn hpgl2_writes_the_palette_and_encoded_polylines() { + let out = plot(&three(), &HpglOptions { version: Version::Hpgl2, ..opts(&three()) }); + let b = &out.bytes; + assert!(b.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;PC3,0,0,255;PU;SP1;PE<="), "{:?}", String::from_utf8_lossy(b)); + assert!(b.ends_with(b";PU;SP0;")); + assert_eq!(b.windows(4).filter(|w| w == b";PE<").count(), 3, "one encoded polyline per pen"); + // Encoded moves take fewer bytes than the same moves written as text. + let plain = plot(&three(), &opts(&three())); + assert!(b.len() < plain.bytes.len(), "{} against {}", b.len(), plain.bytes.len()); +} + +/// A move longer than 8,388,607 units is written absolute (`=`), so every number stays in the +/// range: here from about −5,000,000 to +5,000,000 units around a centered origin. +#[test] +fn hpgl2_writes_a_move_past_the_range_absolute() { + let d = doc_with(|d| { + vec![ + stroked(d, line((-354_000.0, 50.0), (-353_990.0, 50.0)), Color::BLACK), + stroked(d, line((353_990.0, 50.0), (354_000.0, 50.0)), Color::BLACK), + ] + }); + let o = HpglOptions { + version: Version::Hpgl2, + origin: Origin::Center, + region: Rect::new(-400_000.0, 0.0, 400_000.0, 100.0), + ..HpglOptions::default() + }; + let out = plot(&d, &o); + assert_eq!(out.bytes.iter().filter(|b| **b == b'=').count(), 2, "{:?}", String::from_utf8_lossy(&out.bytes)); + let back = super::super::import(&out.bytes).unwrap(); + let mut xs = vec![]; + back.document.walk(|n| { + if let Some(p) = n.path_data() { + xs.extend(p.to_bezpath().elements().iter().filter_map(|e| if let PathEl::MoveTo(p) = e { Some(p.x) } else { None })); + } + }); + assert_eq!(xs.len(), 2); + assert!((xs[1] - xs[0] - 707_990.0).abs() < 0.2, "{xs:?}"); +} + +/// The points of every path `doc` holds, in paint order. +fn polylines(doc: &Document) -> Vec<(Vec<Point>, Option<Paint>)> { + let mut out = vec![]; + doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + let bp = path.to_bezpath(); + let pts = bp.elements().iter().filter_map(|e| match e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => Some(*p), + _ => None, + }); + let stroke = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.paint.clone()) } else { None }); + out.push((pts.collect(), stroke)); + } + }); + out +} + +/// Each version's plot read back: the same lines, within the tolerance and half a plotter unit, +/// and with HP-GL/2 the same colors. +#[test] +fn plots_read_back_as_the_same_lines() { + let d = three(); + for version in Version::ALL { + let out = plot(&d, &HpglOptions { version, ..opts(&d) }); + let back = super::super::import(&out.bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let lines = polylines(&back.document); + assert_eq!(lines.len(), 3, "{version:?}"); + // The reader's artboard is round what the art paints: line the two up by the square. + let shift = lines[0].0[0] - Point::new(10.0, 10.0); + // Two roundings to whole plotter units (1/1016 in) apart at most. + let near = |p: Point, q: Point| (p - shift).distance(q) <= 0.11; + let square = [(10.0, 10.0), (60.0, 10.0), (60.0, 40.0), (10.0, 40.0), (10.0, 10.0)].map(Point::from); + assert!(lines[0].0.iter().zip(square).all(|(p, q)| near(*p, q)) && lines[0].0.len() == 5, "{:?}", lines[0].0); + assert!(near(lines[1].0[0], Point::new(70.0, 20.0)) && near(lines[1].0[1], Point::new(150.0, 80.0)), "{:?}", lines[1].0); + // The ellipse's every point lies on it, within the tolerance and the rounding. + let ellipse = kurbo::Ellipse::new((100.0, 50.0), (30.0, 20.0), 0.0); + for p in &lines[2].0 { + let q = p.to_vec2() - shift - kurbo::Vec2::new(100.0, 50.0); + let r = (q.x / 30.0).hypot(q.y / 20.0); + assert!((r - 1.0).abs() * 20.0 <= DEFAULT_TOLERANCE + 0.1, "{p:?} off the ellipse"); + } + assert!(lines[2].0.len() > 16 && ellipse.area() > 0.0); + if version == Version::Hpgl2 { + let colors: Vec<Option<Paint>> = lines.iter().map(|l| l.1.clone()).collect(); + assert_eq!( + colors, + [Paint::solid(Color::rgb8(0, 0, 0)), Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::rgb8(0, 0, 255))].map(Some) + ); + } + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 525e9b6734..d96df39b6a 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,5 +1,5 @@ //! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an -//! HP-GL/2 plot file reader. +//! HP-GL/2 plot file reader and writer. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -12,13 +12,13 @@ //! clipping) is approximated or left out, and each such loss comes back as a warning. //! //! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. -//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`]. +//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. //! - `writer`: group codes, handles and the file's sections, tables and objects. //! - `scene`: the document walk that turns objects into entities. //! - `import`: the reader, entities to art, blocks to symbols. -//! - `hpgl`: the HP-GL reader, the pen's moves to paths. +//! - `hpgl`: the HP-GL reader, the pen's moves to paths, and the writer, paths to pen moves. mod aci; pub mod hpgl; diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6478cc81b8..7de909e92a 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `hpgl`: HP-GL export options and plots. //! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no //! built-in format reads). //! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). @@ -40,6 +41,7 @@ mod load; mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod heifread; +mod hpgl; mod jp2read; mod metafile; mod naming; diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 97b44597b6..8a82b00a5c 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -526,6 +526,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def | "dxf" | "emf" | "wmf" + | "hpgl" | "ai" ) }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 4fe2d011d9..4ba3be95af 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 84188c313a..0eeff7ab2e 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> { } // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so // themselves: SVG only when it outlines or embeds fonts). - if (f.raster || matches!(f.id, "eps" | "emf" | "wmf")) + if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl")) && let Some(w) = crate::cmd::fonts::substitution_warning(doc) { enc.warnings.push(w); @@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None }; // `rect`: that area of the page, as if it were the only artboard (the document is not changed). if let Some(rect) = page_rect(f, p)? { let mut q = super::export::without_artboards(p); @@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { "eps" => return super::eps::encode(doc, p), "emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf), "wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf), + "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index b04104f70f..aeaf962faf 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -1,6 +1,7 @@ use super::super::unique_name; use super::{ - Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported, + Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, hpgl, json, metafile, native, pdf, screens, svg, text, + unsupported, }; /// One option a format's encoder reads from the export params. @@ -479,8 +480,17 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, - // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them. - reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false), + // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths and written as pen moves. + Format { + id: "hpgl", + label: "HP-GL", + extensions: &["plt", "hpgl", "hgl", "hpg"], + mime: "application/vnd.hp-hpgl", + read: true, + write: true, + raster: false, + options: hpgl::OPTIONS, + }, Format { id: "tga", label: "Targa", diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs new file mode 100644 index 0000000000..0016afa10e --- /dev/null +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -0,0 +1,119 @@ +//! HP-GL for every path that writes plot files (`vectorcraft_cad::hpgl`): `document.export {format: +//! hpgl}` and Export As (one plot of an artboard, or one per artboard with Use Artboards), with the +//! options parsed from the params. + +use serde_json::Value; +use vectorcraft_cad::hpgl::write::{DEFAULT_TOLERANCE, MAX_PENS, TOLERANCE_RANGE}; +use vectorcraft_cad::hpgl::{HpglOptions, Origin, Version}; +use vectorcraft_doc::Document; + +use super::super::{Result, bad, number_or, switch_or, text_param}; +use super::{ArtboardPick, Encoded, FormatOption}; + +const C: &str = "document.export"; + +/// The options `document.formats` lists for HP-GL. +pub(super) const OPTIONS: &[FormatOption] = &[ + super::ARTBOARD, + super::ARTBOARDS, + super::RANGE, + super::USE_ARTBOARDS, + FormatOption { + name: "hpglVersion", + ty: "string", + default: "\"hpgl\"", + description: "hpgl (IN, SP, PU and PD only) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", + }, + FormatOption { + name: "curveTolerance", + ty: "number", + default: "0.1", + description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)", + }, + FormatOption { + name: "pens", + ty: "array", + default: "null", + description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export", + }, + FormatOption { + name: "origin", + ty: "string", + default: "\"bottomLeft\"", + description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)", + }, + FormatOption { + name: "sortPaths", + ty: "boolean", + default: "false", + description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order", + }, +]; + +/// The HP-GL options in `p` (the region is set per plot). +fn parse(p: &Value) -> Result<HpglOptions> { + let mut o = HpglOptions::default(); + if let Some(s) = text_param(p, "hpglVersion", C)? { + let key = "hpglVersion"; + o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?; + } + let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?; + if !TOLERANCE_RANGE.contains(&x) { + let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end()); + return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})"))); + } + o.tolerance = x; + match p.get("pens") { + None | Some(Value::Null) => {} + Some(v) => { + let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect()); + o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?; + } + } + if let Some(s) = text_param(p, "origin", C)? { + let key = "origin"; + o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?; + } + o.sort = switch_or(p, "sortPaths", false, C)?; + Ok(o) +} + +/// A `#rrggbb` color. +fn hex(v: &Value) -> Option<[u8; 3]> { + let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?; + let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + Some([byte(0)?, byte(2)?, byte(4)?]) +} + +/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with +/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all. +pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> { + let opts = parse(p)?; + let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?; + let pick: ArtboardPick = super::encode::options(f, p)?; + let n = doc.artboards.len(); + let boards = match use_artboards { + Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()), + _ => vec![pick.one(n).map_err(|e| bad(C, e))?], + }; + let regions = boards + .iter() + .map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))) + .collect::<Result<Vec<_>>>()?; + let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, ®ions).map_err(|e| bad(C, e))?; + let mut enc = Encoded::default(); + for (b, out) in boards.into_iter().zip(plots) { + enc.files.push((Some(b), out.bytes)); + for w in out.warnings { + if !enc.warnings.contains(&w) { + enc.warnings.push(w); + } + } + } + if enc.files.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); + enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); + Ok(enc) +} diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 56c0d808a5..80b2dca633 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -196,7 +196,7 @@ pub(super) fn name_without_device(name: &str) -> String { pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"]; /// The writable formats a format row can't name. -pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"]; +pub(crate) const UNWRITTEN_FORMATS: [&str; 6] = ["template", "txt", "dxf", "emf", "wmf", "hpgl"]; /// One format row: what to write, at which size, with which suffix, into which sub-folder. struct Row { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 6236d281e2..0e7fba5397 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -418,6 +418,7 @@ fn export_extensions_name_every_writable_format() { "eps", "emf", "wmf", + "plt", "tga", "psd" ] @@ -453,6 +454,7 @@ fn formats_query_lists_readers_writers_and_options() { "eps", "emf", "wmf", + "hpgl", "tga", "psd" ] diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 271f7b86e2..1cb4c03b83 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -1,8 +1,9 @@ //! HP-GL plotter files through the commands: `document.open` by extension or by content, File › -//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't -//! HP-GL refused with a message. +//! Place as one group of paths, the dialogs' filters and the format list, files that aren't +//! HP-GL refused with a message, and `document.export` of one artboard, each or the art, with its +//! options checked. -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::*; @@ -100,5 +101,111 @@ fn the_dialogs_and_the_format_list_name_hpgl() { assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); let r = Session::new().execute("document.formats", &json!({})).unwrap(); let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); - assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false))); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false))); + for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths"] { + assert!(f["options"].get(o).is_some(), "{o}: {f}"); + } + assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl"); + assert_eq!(writable_format(None, Some("x/part.hpgl")).unwrap().id, "hpgl"); +} + +fn bytes(v: &Value) -> Vec<u8> { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red. +fn two_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap(); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap(); + s +} + +#[test] +fn export_writes_a_plot_of_one_artboard_or_each() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap(); + assert_eq!(r["format"], "hpgl"); + // The first artboard's square, 1016 units to the inch from its bottom-left corner. + assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;"); + let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap(); + assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1"); + assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r)); + let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}"); + // One pen numbering for every file: the red square is pen 2 in the second. + assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;"); + // The bounds of the art: both squares, the second pen red, read back as two paths. + let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap()); + assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art)); + let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap(); + assert_eq!(back["format"], "hpgl", "{back}"); + // One file holds one artboard. + assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err()); +} + +/// A plot read back opens with the same squares: as wide as the art drew them, a point at most +/// apart from the 30 pt sides plus the 0.35 mm pen. +#[test] +fn a_plot_reads_back_as_its_art() { + let mut s = two_boards(); + let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap()); + let mut back = Session::new(); + back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap(); + let doc = &back.doc().unwrap().doc; + let mut paths = 0; + doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(paths, 2); + let board = doc.artboards[0].rect; + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + let pen = 0.35 * 72.0 / 25.4; + assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}"); + assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}"); +} + +#[test] +fn export_options_are_checked() { + let mut s = two_boards(); + let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string(); + let cases = [ + (json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"), + (json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"), + (json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"), + (json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"), + (json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"), + (json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"), + ]; + for (p, want) in cases { + let e = refused(&mut s, p.clone()); + assert!(e.contains(want), "{p}: {e}"); + } + let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect(); + assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`")); + // Given options take effect: the center as the origin, a pen table (black is pen 2), and the + // square starting at its corner nearest the origin. + let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true}); + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;"); +} + +/// Export Selection plots the selected square alone (the red one, made last), from the +/// bottom-left corner of its bounds. +#[test] +fn export_selection_writes_a_plot_of_the_selection() { + let mut s = two_boards(); + let r = s.execute("document.exportSelection", &json!({"format": "hpgl"})).unwrap(); + assert_eq!(r["format"], "hpgl"); + let plot = String::from_utf8(bytes(&r)).unwrap(); + assert_eq!(plot.matches("PD").count(), 1, "{plot}"); + assert!(plot.starts_with("IN;PU;SP1;PU7,430;PD"), "{plot}"); } diff --git a/crates/engine/src/tests/areafit.rs b/crates/engine/src/tests/areafit.rs index b6d6883430..7d8f759cf5 100644 --- a/crates/engine/src/tests/areafit.rs +++ b/crates/engine/src/tests/areafit.rs @@ -485,7 +485,7 @@ fn making_and_restyling_type_reports_its_overflow() { } /// The formats whose exports draw type. -const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"]; +const DRAWING_FORMATS: [&str; 9] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf", "hpgl"]; /// The warnings of `document.export {format, …options}` about type that doesn't fit its frame. fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> { diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 00ff3a2d77..54baeeb0c3 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -77,6 +77,7 @@ fn export_tool_lists_engine_formats_and_options() { "eps", "emf", "wmf", + "hpgl", "tga", "psd" ]) diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 17c7ecc912..783f3c0030 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -393,14 +393,14 @@ 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 and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), 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.", + "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, hpgl pen moves for plotters and cutters), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), 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"}, "path": string("Destination file (omit to get dataBase64)"), "scale": num("Raster pixels per point (without scale or options.ppi, uses the document's raster effects resolution; options.ppi wins)"), "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"}, - "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), + "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF, WMF and HP-GL: one, or one file each with options.useArtboards: true)"), "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, "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)"}, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..07c8d79834 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -876,6 +876,9 @@ mod tests_eps; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_hpgl; + #[cfg(test)] mod tests_tiff_bmp_tga; diff --git a/crates/ui-egui/src/dialogs/tests_hpgl.rs b/crates/ui-egui/src/dialogs/tests_hpgl.rs new file mode 100644 index 0000000000..238a254cfb --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_hpgl.rs @@ -0,0 +1,28 @@ +//! Export As → HP-GL, drawn headlessly: listed with the other formats, written straight away (no +//! options dialog), one plot per artboard with Use Artboards. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, names, set}; +use super::*; + +#[test] +fn export_as_lists_hpgl_and_writes_plots() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "hpgl"); + frame(&mut app); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "no options dialog follows"); + assert_eq!(r["path"], "/out/Untitled-1.plt"); + assert!(written.borrow()[0].1.starts_with(b"IN;")); + assert!(written.borrow()[0].1.ends_with(b"PU;SP0;")); + // One plot per artboard. + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + let n = names(&written); + assert_eq!(&n[1..], ["/out/Untitled-1-Artboard-1.plt", "/out/Untitled-1-Artboard-2.plt"]); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..81380a9c3d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4377,6 +4377,7 @@ axis Both И двете @msg not a valid plug-in module: {0} не е валиден модул за плъгин: {0} @msg not an EMF or WMF file не е EMF или WMF файл @msg not an HP-GL file не е HP-GL файл +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` трябва да бъде масив от 1 до 255 цвята във формат #rrggbb @msg not found не е открито @msg not in pattern editing mode не е в режим на редактиране на мостра @msg finish editing the pattern first първо завършете редактирането на мострата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..d92603bb7b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4768,6 +4768,7 @@ imageTrace Outline Obris @msg not a valid plug-in module: {0} nije važeći modul dodatka: {0} @msg not an EMF or WMF file nije EMF ni WMF datoteka @msg not an HP-GL file nije HP-GL datoteka +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` mora biti niz od 1 do 255 boja #rrggbb @msg not an export presets file (format `{_1}`) nije datoteka predložaka izvoza (format `{_1}`) @msg not an export presets file: {e} nije datoteka predložaka izvoza: {e} @msg not found nije pronađeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..b7ec8525ce 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5617,6 +5617,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF @msg not an HP-GL file no és un fitxer HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..63ac301d81 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4851,6 +4851,7 @@ imageTrace Outline Obrys @msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0} @msg not an EMF or WMF file nejde o soubor EMF ani WMF @msg not an HP-GL file nejde o soubor HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku @msg finish editing the pattern first nejprve dokončete úpravy vzorku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..ca09b4bed0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4478,6 +4478,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0} @msg not an EMF or WMF file ikke en EMF- eller WMF-fil @msg not an HP-GL file ikke en HP-GL-fil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` skal være et array af 1 til 255 #rrggbb-farver @msg not found ikke fundet @msg not in pattern editing mode ikke i mønsterredigeringstilstand @msg finish editing the pattern first afslut først redigeringen af mønsteret diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..2ced4ef2a1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4460,6 +4460,7 @@ theme Appearance Erscheinungsbild @msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0} @msg not an EMF or WMF file keine EMF- oder WMF-Datei @msg not an HP-GL file keine HP-GL-Datei +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus @msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..6c31c1d611 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4498,6 +4498,7 @@ digits Malayalam Μαλαγιάλαμ @msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0} @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF @msg not an HP-GL file δεν είναι αρχείο HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου @msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..9f31d491b6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4543,6 +4543,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0} @msg not an EMF or WMF file no es un archivo EMF ni WMF @msg not an HP-GL file no es un archivo HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos @msg finish editing the pattern first termina primero la edición del motivo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..3b98ca2986 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4759,6 +4759,7 @@ imageTrace Outline Ääriviiva @msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0} @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto @msg not an HP-GL file ei HP-GL-tiedosto +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa @msg finish editing the pattern first lopeta ensin kuvion muokkaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..ec2e75ad44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4532,6 +4532,7 @@ axis Both Les deux @msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF @msg not an HP-GL file ce n’est pas un fichier HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif @msg finish editing the pattern first terminez d’abord la modification du motif diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..275f1f0276 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7093,6 +7093,7 @@ antiAlias Sharp शार्प @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..1a895478fd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5228,6 +5228,7 @@ imageTrace Outline Körvonal @msg not a blend nem összemosás @msg not an EMF or WMF file nem EMF- vagy WMF-fájl @msg not an HP-GL file nem HP-GL-fájl +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy @msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..553354beca 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4478,6 +4478,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0} @msg not an EMF or WMF file bukan berkas EMF atau WMF @msg not an HP-GL file bukan berkas HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` harus berupa array berisi 1 sampai 255 warna #rrggbb @msg not found tidak ditemukan @msg not in pattern editing mode tidak dalam mode pengeditan pola @msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..6fd0502fb8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4586,6 +4586,7 @@ axis Both Entrambi @msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0} @msg not an EMF or WMF file non è un file EMF o WMF @msg not an HP-GL file non è un file HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo @msg finish editing the pattern first termina prima la modifica del motivo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..cd6799964b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4656,6 +4656,7 @@ swatch Bright Violet ビビッドバイオレット @msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません @msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません @msg not an HP-GL file HP-GL ファイルではありません +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください @msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません @msg the EMF header is damaged EMF ヘッダーが破損しています @msg damaged 破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..64fd588698 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4303,6 +4303,7 @@ font-technology Variable ცვლადი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not an HP-GL file ეს არ არის HP-GL ფაილი +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული @msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..528a75447c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4583,6 +4583,7 @@ theme Appearance 외관 @msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다 @msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다 @msg not an HP-GL file HP-GL 파일이 아닙니다 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다 @msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다 @msg the EMF header is damaged EMF 헤더가 손상되었습니다 @msg damaged 손상됨 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..7a64d8fbbf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4490,6 +4490,7 @@ imageTrace Outline Contour @msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0} @msg not an EMF or WMF file geen EMF- of WMF-bestand @msg not an HP-GL file geen HP-GL-bestand +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` moet een array van 1 tot 255 #rrggbb-kleuren zijn @msg not found niet gevonden @msg not in pattern editing mode niet in patroonbewerkingsmodus @msg finish editing the pattern first rond eerst het bewerken van het patroon af diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..a76f1839c0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4676,6 +4676,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0} @msg not an EMF or WMF file to nie jest plik EMF ani WMF @msg not an HP-GL file to nie jest plik HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka @msg finish editing the pattern first najpierw zakończ edycję wzorka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..5669345067 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4082,6 +4082,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0} @msg not an EMF or WMF file não é um arquivo EMF ou WMF @msg not an HP-GL file não é um arquivo HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão @msg finish editing the pattern first termine de editar o padrão primeiro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..4426c78863 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4580,6 +4580,7 @@ axis Both Оба @msg not a valid plug-in module: {0} не является допустимым модулем: {0} @msg not an EMF or WMF file не является файлом EMF или WMF @msg not an HP-GL file не является файлом HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора @msg finish editing the pattern first сначала завершите редактирование узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..0264c0f1c3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4447,6 +4447,7 @@ axis Both Båda @msg not a valid plug-in module: {0} inte ett giltigt plugin: {0} @msg not an EMF or WMF file inte en EMF- eller WMF-fil @msg not an HP-GL file inte en HP-GL-fil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering @msg finish editing the pattern first slutför redigeringen av mönstret först diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..2dca3b1d7e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4301,6 +4301,7 @@ axis Both Her İkisi @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @msg not an EMF or WMF file EMF veya WMF dosyası değil @msg not an HP-GL file HP-GL dosyası değil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..38b8a66d1c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4570,6 +4570,7 @@ axis Both Обидві @msg not a valid plug-in module: {0} не є допустимим модулем: {0} @msg not an EMF or WMF file не є файлом EMF або WMF @msg not an HP-GL file не є файлом HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка @msg finish editing the pattern first спочатку завершіть редагування візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..5929e2b253 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4883,6 +4883,7 @@ theme Appearance Giao diện @msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được. @msg not an EMF or WMF file không phải tệp EMF hoặc WMF @msg not an HP-GL file không phải tệp HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết @msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..6994dec220 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4362,6 +4362,7 @@ imageTrace Outline 轮廓 @msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 @msg not an EMF or WMF file 不是 EMF 或 WMF 文件 @msg not an HP-GL file 不是 HP-GL 文件 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组 @msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 @msg the EMF header is damaged EMF 文件头已损坏 @msg damaged 已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..294e3bf25f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2174,6 +2174,7 @@ file-filter Templates 範本 @msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖 @msg not an EMF or WMF file 不是 EMF 或 WMF 檔案 @msg not an HP-GL file 不是 HP-GL 檔案 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列 @msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取 @msg the EMF header is damaged EMF 檔頭已損毀 @msg damaged 已損毀 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..bd15a3bec5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -796,6 +796,9 @@ EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. +HP-GL: Export As… lists it (`file.exportAs {format: "hpgl"}`); it has no options dialog, so `ui.dialog.confirm` asks +for the path and writes the `.plt` file with the default options (one per artboard with Use Artboards). + TIFF, BMP and Targa Options: Export As… with the TIFF, BMP or Targa format (`file.exportAs {format: "tiff"}`, then the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa682c0272..ba2ddfdaa8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -291,7 +291,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | -| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` | +| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..6843a543ab 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -510,7 +510,7 @@ applied colors still join the list while it is hidden. | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | @@ -6292,13 +6292,55 @@ from the origin, so P2 lies there when the file sets no scaling points. `SP0` draws nothing. Labels, the symbols of symbol mode (`SM`) and raster images are not drawn, the input window (`IW`) does not clip the art, the art of later pages is drawn over the first page, and hatched and patterned fills are filled solid; `warnings` lists each of these. A file named as a plot file that doesn't start with plotter -instructions is refused with "not an HP-GL file". VectorCraft does not write HP-GL. +instructions is refused with "not an HP-GL file". ```json {"name":"open_file","arguments":{"path":"/tmp/part.plt"}} {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/part.plt","at":[300,200]}}} ``` +## HP-GL export + +`document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard +(or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard +(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: +a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so +fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a +gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A +symbol instance is drawn as its symbol's art with the art's effects, in the colors of the instance's stain. +Clipping masks cut the art they clip, and the art is cut at the artboard's edges. Hidden and non-printing layers, +hidden objects, objects at 0% opacity, template layers and guides are left out, and so are placed images, with a +warning. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner, or its +center with `origin: "center"`, rounded to whole units, as the HP-GL/2 and HP RTL Reference Guide defines them +([book listing](https://informit.com/store/hp-gl-2-and-hp-rtl-reference-guide-a-handbook-for-program-9780201310146); +the [vendor's support page](https://support.hp.com/sg-en/document/c01121263) names it as the language reference). +The guide also gives the instruction syntax and the encoding of `PE` numbers that the writer follows. Art more than +8,388,607 units (about 210 m) from the origin is cut off, with a warning. + +Each color is a pen. Pen 1 is the first color the plot draws in, pen 2 the next new one, and so on, up to 255 pens; +colors past those draw with the pen of the nearest color. The files of one export share the numbering, so a color is +the same pen in each file. `pens: ["#000000", "#ff0000"]` gives the pen table instead (pen 1 first): each path draws +with the pen of the nearest color, the lower pen when two are as near. A path's color is its topmost visible stroke's, +else its topmost visible fill's, where a fill or stroke at 0% opacity does not count. A gradient's color is the average +of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray +when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path +the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one +starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the +curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above +are VectorCraft's own. + +`hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and +ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's +paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names +type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke +looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping +work, symbols, live art, nesting depth and sorting. + +```json +{"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +``` + ## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..ef3d1a2c11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1494,6 +1494,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). From 35be7952c8a6cabb3d08540f4bd446bd5c8b7cc2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:26:11 -0800 Subject: [PATCH 050/126] Save and Export: the status bar's hover lists every note A save or an export with several notes named only the first of them in the status bar, with the count, and the others could not be seen. The status bar now keeps every note of a save or an export for the status message's hover, as it does for an open's notes since #6752: resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), and screen readers receive the list after the message. The status text is unchanged. This covers Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file and the CSS Properties panel's export. The open, the saves and the exports read a reply's notes through one helper. Docs: docs/mcp.md and the css.exportFile params text, docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/chrome/status_bar.rs | 5 +-- crates/ui-egui/src/dialogs/save_for_web.rs | 10 +++--- crates/ui-egui/src/dialogs/tests_screens.rs | 29 ++++++++++++++++ crates/ui-egui/src/io.rs | 34 +++++++++++++++++-- crates/ui-egui/src/io/export.rs | 20 ++++++----- crates/ui-egui/src/io/open.rs | 8 ++--- crates/ui-egui/src/io/save.rs | 22 +++++++++--- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/css_properties.rs | 11 +++--- .../src/panels/tests_css_properties.rs | 20 +++++++++++ crates/ui-egui/src/state.rs | 5 +-- docs/control-protocol.md | 8 ++--- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 14 files changed, 139 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9501d9f83b..1e946b56da 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -248,8 +248,9 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { /// Most notes the status message's hover and access information list. const MAX_LISTED_NOTES: usize = 30; -/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when -/// there are several and the status bar still shows the message they came with, else none. +/// The notes behind the status message (an open's, a save's or an export's, +/// [`crate::state::UiState::status_notes`]) when there are several and the status bar still shows +/// the message they came with, else none. fn status_notes(ui: &crate::state::UiState) -> &[String] { let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1); notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default() diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 924f8c4078..64dc2fae8c 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -133,8 +133,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { } /// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML -/// output, else the image); the web downloads them. The status bar shows the export's first -/// warning. +/// output, else the image); the web downloads them. The status bar shows the export's warnings as +/// [`io::report_saved`] shows a save's notes. pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> { // Bad settings never open a save panel. let s = app.session.web_settings(&p)?; @@ -170,7 +170,7 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri } /// Whether an export's reply `r` has warnings (an image that couldn't be decoded): -/// [`io::report_saved`] puts the first in the status bar as a save's note. +/// [`io::report_saved`] shows them in the status bar as a save's notes. fn has_warnings(r: &Value) -> bool { r["warnings"].as_array().is_some_and(|w| !w.is_empty()) } @@ -183,8 +183,8 @@ pub(super) fn preview_name(stem: &str) -> &str { } /// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the -/// default browser (desktop). When the export has warnings, the status bar shows the first in -/// place of "Opened …". +/// default browser (desktop). When the export has warnings, the status bar shows them as +/// [`io::report_saved`] shows a save's notes. pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { if io::is_web(app) { return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into()); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 140623f209..97e2b16ee7 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -508,6 +508,35 @@ fn the_status_bar_says_when_an_image_could_not_be_decoded() { let _ = std::fs::remove_dir_all(dir); } +/// An export into a folder with several notes, from Export for Screens and from the Asset Export +/// panel: the status bar names the first, counts them, and keeps every one of them for its hover. +#[test] +fn an_export_into_a_folder_keeps_every_note_for_the_hover() { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let dir = temp_folder("notes"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}]); + let (mut app, _) = app(1, false); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({"at": [10, 10]})); + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(app.ui.status.starts_with(&format!("Saved {folder} with 2 notes: {}", notes[0])), "{}", app.ui.status); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + app.ui.status_notes = None; + app.run("select.all", json!({})).unwrap(); + app.run("assets.add", json!({"multiple": false})).unwrap(); + let r = crate::io::export_files(&mut app, "assets.export", json!({"folder": folder, "formats": rows})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)), "assets.export"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_preset_export_reopens_on_its_rows() { let (mut app, _) = app(1, true); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 93abdfd31c..dff4fa0dd2 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -30,7 +30,7 @@ pub use open::{ #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; -pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; +pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to}; #[cfg(test)] pub(crate) use save::{report_write_error, saved_status}; @@ -90,7 +90,7 @@ mod tests { use vectorcraft_testkit::ai; let (mut app, _) = app(); let damaged = b"%AI12_CompressedData not zlib"; - let notes = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + let notes = notes_of; let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f"); let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); let [page, layers] = ¬es(&r)[..] else { panic!("two notes: {r}") }; @@ -125,6 +125,36 @@ mod tests { assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown); } + /// A save and an export with several notes: the status bar names the first, counts them, and + /// keeps every one of them, in the reply's order, for its hover. A save without notes keeps + /// none. + #[test] + fn a_save_and_an_export_keep_every_note_for_the_hover() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let r = app.run("file.save", json!({"path": "/out/a.svg", "compress": true})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(notes[0].starts_with("SVG keeps the artwork but not everything"), "{r}"); + assert_eq!(app.ui.status, saved_status("/out/a.svg", &r)); + assert!(app.ui.status.contains("with 2 notes"), "{}", app.ui.status); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + + app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap(); + assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None)); + + // A PDF export of a knockout group and of type in a font that isn't installed. + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("transparency.set", json!({"knockout": "on"})).unwrap(); + let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), ¬es[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 562dc0e00a..246511669e 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,6 @@ use super::save::{ - ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path, - target_path_keeping, view_file, write_encoded, write_named, write_reporting, + ask_before_losing, export_path, is_web, notes_of, remember_active_artboard, report_saved, report_write_error, saved_status, show_with_notes, + target_path, target_path_keeping, view_file, write_encoded, write_named, write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -94,11 +94,12 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin Some(files) => format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), }; - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - app.status(match warnings.first() { + let warnings = notes_of(&r); + let status = match warnings.first() { Some(first) => exported_with_notes(&what, warnings.len(), first), None => format!("Exported {what}"), - }); + }; + show_with_notes(app, status, warnings); Ok(r) })?; if r.get("path").is_none() { @@ -115,7 +116,7 @@ pub(crate) fn exported_with_notes(what: &str, n: usize, first: &str) -> String { /// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path /// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' -/// Export…), reported with the first of the command's warnings ([`report_saved`]). +/// Export…), reported with the command's warnings ([`report_saved`]). pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result<String, String> { let (path, v) = run_to_file(app, id, ext, params)?; report_saved(app, &path, &v); @@ -241,8 +242,9 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), /// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; /// without one the web downloads them (the ZIP, or each file) and the other frontends get them /// back as base64. With `openLocation`, the file manager shows the first file written. When an -/// export into a folder or a download has warnings, the status bar shows the first as -/// [`report_saved`] shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …". +/// export into a folder or a download has warnings, the status bar shows them as [`report_saved`] +/// shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …", and hovering the +/// message lists them all. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> { export_files(app, "document.exportForScreens", params) } @@ -312,7 +314,7 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati { status = format!("{status} ({e})"); } - app.status(status); + show_with_notes(app, status, notes_of(&r)); } else if let Some(download) = app.services.download.as_mut() { // A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone. let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect(); diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 3ca34cdd77..0328d2b07b 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,7 +1,7 @@ use vectorcraft_engine::cmd::plugin::InstallApproval; use vectorcraft_plugins::permissions; -use super::save::suggested; +use super::save::{notes_of, show_with_notes, suggested}; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; /// Template extensions New from Template's open dialog lists first. @@ -144,8 +144,8 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { app.sync_views(); let notes = open_notes(app, r); if let Some(first) = notes.first() { - app.status(opened_with_notes(notes.len(), first)); - app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let status = opened_with_notes(notes.len(), first); + show_with_notes(app, status, notes); } crate::dialogs::missing_links::after_open(app, r); } @@ -153,7 +153,7 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { /// The notes of the open `r`, those of what reading the file left out (its document's /// `import_losses`) first, each group in the order the import gave them. fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec<String> { - let notes: Vec<String> = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let notes = notes_of(r); let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()); let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default(); let (mut first, rest): (Vec<String>, Vec<String>) = notes.into_iter().partition(|n| losses.contains(n)); diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..708557fc4a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -394,20 +394,34 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { } } -/// "Saved <path>", with the first of the result's warnings. +/// "Saved <path>", with the first of the result's warnings and how many there are; hovering the +/// message lists them all ([`show_with_notes`]). pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { - app.status(saved_status(path, r)); + show_with_notes(app, saved_status(path, r), notes_of(r)); +} + +/// The warnings of a reply `r`, in the reply's order (a save's notes start with what the format +/// loses). +pub(crate) fn notes_of(r: &Value) -> Vec<String> { + r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() +} + +/// Show `status`, which names the first of `notes`, in the status bar and keep `notes` for its +/// hover ([`crate::state::UiState::status_notes`]). Without notes nothing is kept. +pub(crate) fn show_with_notes(app: &mut VectorcraftApp, status: String, notes: Vec<String>) { + app.status(status); + app.ui.status_notes = (!notes.is_empty()).then(|| (app.ui.status.clone(), notes)); } /// [`report_saved`]'s message. pub(crate) fn saved_status(path: &str, r: &Value) -> String { - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let warnings = notes_of(r); match warnings.first() { Some(first) => crate::i18n::msg_n( warnings.len() as u64, "Saved {path} with {n} note: {first}", "Saved {path} with {n} notes: {first}", - &[("path", path), ("first", first)], + &[("path", path), ("first", first.as_str())], ), None => format!("Saved {path}"), } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 19a6a1da67..6c842814e1 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1487,7 +1487,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists them all)}", ), ( "file.print", diff --git a/crates/ui-egui/src/panels/css_properties.rs b/crates/ui-egui/src/panels/css_properties.rs index bc362fb576..eae559dff5 100644 --- a/crates/ui-egui/src/panels/css_properties.rs +++ b/crates/ui-egui/src/panels/css_properties.rs @@ -86,7 +86,7 @@ fn shown(app: &mut VectorcraftApp, ui: &Ui, opts: &CssOptions, all: bool) -> Opt let (id, scope) = if all { ("css.generate", "all") } else { ("css.selection", "selection") }; let out = app.session.execute(id, ¶ms(opts, scope)).unwrap_or_default(); let rules: Vec<&str> = out["rules"].as_array().into_iter().flatten().filter_map(|r| r["css"].as_str()).collect(); - let warnings = out["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let warnings = io::notes_of(&out); let job = (!rules.is_empty()) .then(|| Arc::new(highlight(rules.get(..MAX_SHOWN).unwrap_or(&rules), &t, egui::TextStyle::Monospace.resolve(ui.style())))); let skipped = out["skipped"].as_u64().unwrap_or(0); @@ -204,13 +204,14 @@ pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result<Value, io::write_to(&mut app.services, &at, &bytes)?; images.push(at); } - // The status bar shows the first warning in Export As's message. - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + // The status bar shows the warnings as Export As shows them. + let warnings = io::notes_of(&r); let what = &path; - app.status(match warnings.first() { + let status = match warnings.first() { Some(first) => io::exported_with_notes(what, warnings.len(), first), None => format!("Exported {path}"), - }); + }; + io::show_with_notes(app, status, warnings); Ok(json!({ "path": path, "rules": r["rules"], "images": images, "warnings": r["warnings"] })) } diff --git a/crates/ui-egui/src/panels/tests_css_properties.rs b/crates/ui-egui/src/panels/tests_css_properties.rs index 619be4d70b..e03fc9a59c 100644 --- a/crates/ui-egui/src/panels/tests_css_properties.rs +++ b/crates/ui-egui/src/panels/tests_css_properties.rs @@ -146,3 +146,23 @@ fn export_says_in_the_status_bar_when_an_image_could_not_be_decoded() { assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 1 note: {WARNING}")); assert_eq!(log.borrow().len(), 3, "the sheet and the image's picture are still written"); } + +/// A CSS export with several notes: the status bar names the first, counts them, and keeps every +/// one of them for its hover. +#[test] +fn export_keeps_every_note_for_the_hover() { + let (mut app, _) = app(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({})); + // Type painted with a gradient is rasterized, and its picture is drawn in the fallback font. + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + app.run("paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap(); + let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 2 notes: {}", notes[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 52e492d4d3..0059c176f4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -537,8 +537,9 @@ pub struct UiState { /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. #[serde(skip)] pub replace_font_warning: Option<ReplaceFontWarning>, - /// The notes of the last open that gave any, with the status text that names the first of - /// them: hovering that text lists them when there are several. Not saved. + /// The notes of the last open that gave any, or of the last save or export, with the status + /// text that names the first of them: hovering that text lists them when there are several. + /// Not saved. #[serde(skip)] pub status_notes: Option<(String, Vec<String>)>, pub palette_open: bool, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..1341440a41 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | @@ -118,13 +118,13 @@ the two can still win that race. | `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | | `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | -| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | +| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | -| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes | +| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | @@ -813,7 +813,7 @@ a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit` the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser` writes the HTML page to a temporary folder and opens it in the default browser (desktop). Both return the export's -`warnings`, and the status bar shows the first one as Save As shows a save's notes. +`warnings`, and the status bar shows them as Save As shows a save's notes. PSD Options: Export As… with the PSD format (`file.exportAs {format: "psd"}`, then the save dialog) opens `psdOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..4905ee30f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6385,7 +6385,8 @@ point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web -downloads it) and shows the first note in the status bar. +downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the +message lists them all. ```json {"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..4dc4fcf315 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -577,6 +577,7 @@ since 2026-10-01. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. +- **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. From dfa8403ed09b21276d5c7fc147f9b99c542d653d Mon Sep 17 00:00:00 2001 From: makesomethingshit <junsoo1172@gmail.com> Date: Thu, 12 Nov 2026 13:46:51 +0900 Subject: [PATCH 051/126] Fix #6373: Keep painting through long Live Paint drags --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 50 +++++++++++++------ crates/engine/src/tests/build.rs | 43 ++++++++++++++++ crates/tools/src/builder.rs | 4 +- crates/tools/src/builder/live_paint.rs | 29 ++++++++--- crates/tools/src/builder/tests.rs | 19 +++++++ 6 files changed, 123 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index b6a0c9c9e9..60a6b81378 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 5f4b1205f3..3f9b62df21 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -272,17 +272,34 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let points = if let Some(input) = p.get("points") { - if input.as_array().is_none_or(|points| points.is_empty() || points.len() > 4096) { - return Err(bad(C, "points must contain 1 to 4096 points")); + let parse_chunk = |input: &Value, start: Point| -> Result<Vec<Point>> { + let values = input + .as_array() + .filter(|points| !points.is_empty() && points.len() <= b::LP_FILL_CHUNK_POINTS) + .ok_or_else(|| bad(C, "each path chunk must contain 1 to 4096 points"))?; + let points: Vec<_> = values + .iter() + .map(|point| { + let pair = point.as_array().filter(|pair| pair.len() == 2).ok_or_else(|| bad(C, "points must be [x,y] pairs"))?; + let x = pair.first().and_then(Value::as_f64).ok_or_else(|| bad(C, "point x must be a number"))?; + let y = pair.get(1).and_then(Value::as_f64).ok_or_else(|| bad(C, "point y must be a number"))?; + let point = Point::new(x, y); + if !finite(point) { + return Err(bad(C, "points must be finite canvas points")); + } + Ok(point) + }) + .collect::<Result<_>>()?; + if points.first() != Some(&start) { + return Err(bad(C, "path chunks must share their boundary point")); } - let points = super::points_param(p, "points").ok_or_else(|| bad(C, "points must be [[x,y]...]"))?; - if points.first() != Some(&pt) || !points.iter().all(|q| finite(*q)) { - return Err(bad(C, "points must start at point and contain 1 to 4096 finite canvas points")); - } - Some(points) - } else { - None + Ok(points) + }; + let points = p.get("points"); + let continuation = match p.get("continuation") { + None => &[][..], + Some(input) if points.is_some() => input.as_array().ok_or_else(|| bad(C, "continuation must be an array of path chunks"))?.as_slice(), + Some(_) => return Err(bad(C, "continuation requires points")), }; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); @@ -314,8 +331,8 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { .and_then(|n| b::visible_face_at(n, pt)) .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - let mut targets = vec![fid]; - if let Some(points) = &points { + let mut targets = std::collections::BTreeSet::from([fid]); + if let Some(points) = points { let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; // Use the existing region-crossing probes, so even a face narrower than the distance // between pointer events is filled. Reversing matches visible_face_at's front first. @@ -331,9 +348,12 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { } } let map = b::BuilderMap { regions, ..Default::default() }; - targets.extend(map.hits_along(points, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); - targets.sort(); - targets.dedup(); + let mut last = pt; + for input in std::iter::once(points).chain(continuation) { + let chunk = parse_chunk(input, last)?; + last = chunk.last().copied().unwrap_or(last); + targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); + } } for target in targets { d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index b5b4f35e77..0fc2bcb476 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3003,6 +3003,42 @@ fn live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel() { } } +#[test] +fn live_paint_bucket_long_drag_keeps_late_turns_and_release_as_one_undo() { + for cancel in [false, true] { + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (100.0, 0.0), (100.0, 100.0))]; + s.execute("select.set", &json!({"ids": ids.map(|id| id.0)})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.prefs.real_time_drawing = false; + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + for i in 1..4096 { + s.pointer(&PointerEvent::new(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 50.0), v).unwrap(); + } + // Turn around the middle face after the chunk limit, then enter the right face on Up. + for (x, y) in [(25.0, 150.0), (125.0, 150.0)] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap(); + } + if cancel { + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + if !cancel { + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + for x in [25.0, 125.0] { + assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), s.paint.fill); + } + assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(75.0, 50.0)).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE)); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc == before, "long drag is one cancellable undo step"); + } +} + #[test] fn live_paint_bucket_drag_rejects_invalid_points_atomically() { let mut s = session(); @@ -3014,6 +3050,13 @@ fn live_paint_bucket_drag_rejects_invalid_points_atomically() { assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": points})).is_err()); assert_eq!(s.doc().unwrap().doc, before); } + for continuation in [json!(null), json!([[[26.0, 50.0], [125.0, 50.0]]]), json!([vec![[25.0, 50.0]; 4097]])] { + assert!( + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": [[25.0, 50.0]], "continuation": continuation})) + .is_err() + ); + assert_eq!(s.doc().unwrap().doc, before); + } } #[test] diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index b8e5840c26..1c5a895161 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -37,8 +37,8 @@ use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache}; use live_paint::LpHover; use live_paint::lp_color; pub use live_paint::{ - LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, - LivePaintSelectionTool, + LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_FILL_CHUNK_POINTS, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, + LivePaintBucketTool, LivePaintSelectionTool, }; pub use lookup::{ EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 4822617ad1..010e423743 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -132,7 +132,7 @@ pub struct LivePaintBucketTool { /// Where the pointer last moved, and whether Shift and Alt were held. at: Option<(Point, bool, bool)>, /// The fill command and polyline previewed from the press's snapshot. - fill_drag: Option<(Value, Vec<Point>)>, + fill_drag: Option<(Value, Vec<Vec<Point>>)>, fills: bool, strokes: bool, highlight: bool, @@ -140,6 +140,9 @@ pub struct LivePaintBucketTool { width: f64, } +/// Maximum points in one Live Paint fill path chunk. +pub const LP_FILL_CHUNK_POINTS: usize = 4096; + impl Default for LivePaintBucketTool { fn default() -> Self { Self { @@ -158,18 +161,32 @@ impl Default for LivePaintBucketTool { impl LivePaintBucketTool { fn begin_fill(&mut self, params: Value, p: Point) -> Vec<Action> { - self.fill_drag = Some((params.clone(), vec![p])); + self.fill_drag = Some((params.clone(), vec![vec![p]])); vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)] } fn drag_fill(&mut self, p: Point) -> Vec<Action> { - let Some((params, points)) = &mut self.fill_drag else { return vec![] }; - if points.last() == Some(&p) || points.len() >= 4096 { + let Some((params, chunks)) = &mut self.fill_drag else { return vec![] }; + let Some(points) = chunks.last_mut() else { return vec![] }; + let Some(&last) = points.last() else { return vec![] }; + if last == p { return vec![]; } - points.push(p); + if points.len() == LP_FILL_CHUNK_POINTS { + // Share the boundary point so no segment is lost between chunks. + chunks.push(vec![last, p]); + } else { + points.push(p); + } let mut params = params.clone(); - params["points"] = json!(points.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()); + let mut chunks = chunks.iter().map(|chunk| json!(chunk.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())); + if let Some(first) = chunks.next() { + params["points"] = first; + } + let continuation: Vec<_> = chunks.collect(); + if !continuation.is_empty() { + params["continuation"] = Value::Array(continuation); + } vec![Action::Preview("livePaint.fill".into(), params)] } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 35ef46e629..eeaf8f0ea7 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1045,6 +1045,25 @@ fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) { (gid, ids) } +#[test] +fn bucket_long_drag_keeps_turns_and_release_after_the_point_limit() { + let (d, gid, ..) = live_group_doc(); + let s = Selection::default(); + let paint = paint(); + let cx = cx(&d, &s, &paint); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + for i in 1..4096 { + t.pointer(&cx, &ev(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 25.0)); + } + t.pointer(&cx, &ev(PointerKind::Drag, 25.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 100.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); + let [Action::Preview(id, params), Action::Commit] = acts.as_slice() else { panic!("release must extend the drag before committing") }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(params["group"], gid.0); +} + #[test] fn bucket_click_fills_face_and_shift_click_strokes_edge() { let (d, gid, f2, _e) = live_group_doc(); From 0996268f87af1529f9658f4053523b5c4994be9e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:54:00 -0800 Subject: [PATCH 052/126] JavaScript: a host evaluator's whole source must fit in the heap before it is encoded The fuel and heap checks for a host evaluator's source were split into a third before encoding and the rest after, so a source whose whole UTF-16 text did not fit in the heap could still be encoded and parsed. The whole encoded size is now reserved before encoding. A source over the source limit is still refused as a syntax error. --- crates/js/src/host.rs | 10 ++++++++-- crates/js/src/host_tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index e66b109b03..9c14bedca1 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -398,9 +398,15 @@ impl Interp { self.evaluated = self.evaluated.saturating_add(1); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); // UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the - // bytes is taken before the source is encoded, and the rest after. + // bytes is taken before the source is encoded, and the rest after. A source within the + // source limit must fit in the heap whole before it is encoded; a longer one is refused as + // a syntax error when it is encoded. let floor = source.len().div_ceil(3); - let code = self.prepare_source(floor).map(|()| { + let fits = |interp: &mut Self| { + let units = source.encode_utf16().count(); + if interp.check_source_units(units).is_ok() { interp.reserve_heap(string_bytes(units)) } else { Ok(()) } + }; + let code = self.prepare_source(floor).and_then(|()| fits(self)).map(|()| { let units = self.encode_source(&source); let rest = match &units { Ok(units) => self.prepare_source(units.len().saturating_sub(floor)), diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index fe7ce894f9..91f3755122 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -561,6 +561,24 @@ fn an_evaluators_source_takes_fuel_before_it_is_parsed() { assert_eq!(ends.get(), 3); } +#[test] +fn an_evaluators_whole_source_must_fit_in_the_heap_before_it_is_parsed() { + // A third of the source and the other two thirds each fit in the heap; the whole does not. + // The source does not parse, so a parse would throw a syntax error instead. + let n = (1 << 17) + 1; + let source: HostFunction = Rc::new(move |_| Ok(Data::String(format!("{}(", " ".repeat(n - 1))))); + let item = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + let mut r = realm(1_000_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + r.interp().collect_for_room(); + let heap_bytes = r.interp().heap.bytes + crate::heap::string_bytes(n * 5 / 6); + r.set_limits(Limits { fuel: 1_000_000, heap_bytes, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Heap)); + // With room for the whole source, it is parsed. + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Thrown); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] From 605a74f19f656c4d8331e0ff0f6ba4e2e2bb0777 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 20:56:36 -0800 Subject: [PATCH 053/126] Artboards: Convert to Artboards gives each new artboard its id without reading every artboard Convert to Artboards found the highest artboard id twice for each object it converted: once for the new artboard's name and once in Document::next_artboard_id. Converting N objects read about N^2 artboards. The command now keeps the highest id as it adds artboards, and Document::next_artboard_id_above gives the next id from it. When an artboard has id u32::MAX, it falls back to next_artboard_id, which takes the smallest free id. The ids and names it gives are unchanged. --- crates/doc/src/document_guides.rs | 13 ++++++++++++ crates/engine/src/cmd/menucmds.rs | 7 +++++-- crates/engine/src/tests/menucmds.rs | 32 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index ca6b5ed882..4ec7aab1f6 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -503,6 +503,19 @@ impl Document { id } + /// [`Document::next_artboard_id`] when `top` is the highest id the artboards have (`None`: there + /// are none). It reads no artboard unless that id is `u32::MAX`, so a caller that adds many + /// artboards and keeps `top` up to date gets each id in constant time. + pub fn next_artboard_id_above(&self, top: Option<u32>) -> u32 { + match top.unwrap_or(0).max(self.retired_artboard_id.get()).checked_add(1) { + Some(id) => { + self.retired_artboard_id.raise(id); + id + } + None => self.next_artboard_id(), + } + } + /// Record that the artboard with `id` was deleted: no later artboard takes its id. pub fn retire_artboard_id(&self, id: u32) { self.retired_artboard_id.raise(id); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 8ea016db65..90e02bac72 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -1306,13 +1306,16 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> { let symbols = d.symbol_boxes(); let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect(); let mut converted = vec![]; + // The highest artboard id the document has, kept up to date as artboards are added. + let mut top = d.artboards.iter().map(|a| a.id).max(); for (id, b) in boxes { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } // Named one past the highest artboard id the document has, whatever id it is given. - let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1)); - let aid = d.next_artboard_id(); + let number = top.map_or(1, |m| m.saturating_add(1)); + let aid = d.next_artboard_id_above(top); + top = top.max(Some(aid)); d.artboards.push(vectorcraft_doc::Artboard { id: aid, name: format!("Artboard {number}"), diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index fccbe4a2d3..28cb0a1590 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1855,6 +1855,38 @@ fn convert_to_artboards_names_by_the_artboards_the_document_has() { assert_eq!(name_after(&|s| _ = s.execute("edit.undo", &json!({})).unwrap()), "Artboard 3"); } +/// Convert to Artboards on several objects gives each new artboard an id above the ids given out +/// before and the deleted artboard's, and names it one past the highest id the artboards have, +/// counting the artboards it has just added. With an artboard whose id is `u32::MAX` (a damaged +/// file), each new artboard takes the smallest free id and the name stays at `u32::MAX`. +#[test] +fn convert_to_artboards_gives_each_object_the_next_id_and_name() { + let convert = |s: &mut Session| { + let ids: Vec<NodeId> = (0..3).map(|i| rect(s, 3000.0 + 300.0 * f64::from(i), 0.0, 200.0, 100.0)).collect(); + sel(s, &ids); + assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 3); + let d = &s.doc().unwrap().doc; + d.artboards.iter().map(|a| (a.id, a.name.clone())).collect::<Vec<_>>() + }; + let named = |pairs: &[(u32, &str)]| pairs.iter().map(|&(id, n)| (id, n.to_string())).collect::<Vec<_>>(); + + // Artboards 1 and 2 are left after artboard 3 is deleted, so its id stays given out. + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + let ids: Vec<u32> = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + assert_eq!(ids, [1, 2]); + let got = convert(&mut s); + assert_eq!(got, named(&[(1, "Artboard 1"), (2, "Artboard 2"), (4, "Artboard 3"), (5, "Artboard 5"), (6, "Artboard 6")])); + + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[0].id = u32::MAX; + let got = convert(&mut s); + let top = format!("Artboard {}", u32::MAX); + assert_eq!(got, named(&[(u32::MAX, "Artboard 1"), (1, &top), (2, &top), (3, &top)])); +} + /// Rearrange All Artboards refuses `columns` that are not a whole number (a fraction, a negative number or a string), /// and the artboards stay where they were; 1.0, as the Rearrange All Artboards dialog sends a count, is one column. #[test] From f526c5310c2743fed82d9d5f8dcc7fd4df4e3423 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:01:32 -0800 Subject: [PATCH 054/126] JS: XML and XMLList xpath evaluates XPath 1.0 expressions xml.xpath(expression[, variables]) evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with the XML value as the context node and returns the selected nodes as an XMLList, in document order and without repeats. On an XMLList the expression runs on each element in turn, and the results are joined. - All 13 axes and the 27 core functions. id() selects nothing, since E4X trees carry no DTD data. - Each property of `variables` binds the variable of its name. A prefix in a name test resolves through the context element's in-scope namespaces; `xml` is always bound. - An expression that breaks the grammar gives a SyntaxError. An unknown function, a wrong argument count, an unbound prefix or variable, a result that is not a node-set, and a result that holds the root or a namespace node give a TypeError. - The parser and the evaluator keep their state on their own stacks, so expressions of any depth use no native stack; nesting past Options::max_depth gives "nesting too deep". Traversal stacks, node lists, strings and the values that string functions build count against the heap limit, and every node visited and string read takes fuel. Docs: docs/target-app-parity.md (E4X line), crate doc in lib.rs. --- crates/js/src/lib.rs | 7 +- crates/js/src/string_builtins.rs | 4 +- crates/js/src/xml_builtins.rs | 9 +- crates/js/src/xml_tests.rs | 467 +++++- crates/js/src/xpath.rs | 2377 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 2857 insertions(+), 9 deletions(-) create mode 100644 crates/js/src/xpath.rs diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 7df9953a67..cad290f1af 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -41,9 +41,11 @@ //! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): //! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and //! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, -//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! `isXMLName` and `setDefaultXMLNamespace`, and XML and XMLList values have an `xpath` method that +//! evaluates an XPath 1.0 expression. Without the option, the language and the built-ins are //! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and -//! serializing them use no native recursion and take fuel for each node. +//! serializing them, and evaluating XPath on them, use no native recursion and take fuel for each +//! node. #![forbid(unsafe_code)] pub mod ast; @@ -70,6 +72,7 @@ mod token; mod value; mod xml; mod xml_builtins; +mod xpath; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf54..6b963e0ee2 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -98,7 +98,7 @@ fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> { /// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must /// grow. -fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, item: T) -> JsResult<()> { +pub(crate) fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, item: T) -> JsResult<()> { if list.len() == list.capacity() { let more = list.capacity().max(4); held.add(interp, more.saturating_mul(size_of::<T>()))?; @@ -179,7 +179,7 @@ fn clamp(n: f64, len: usize) -> usize { /// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes /// fuel. -fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult<Option<usize>> { +pub(crate) fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult<Option<usize>> { if needle.len() > hay.len() { return Ok(None); } diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index c74b9fac2e..9cd797f48a 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -14,6 +14,7 @@ use crate::value::{Str, Value}; use crate::xml::{ Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, }; +use crate::xpath::xpath; const SETTINGS: [(&str, Value); 5] = [ ("ignoreComments", Value::Bool(false)), @@ -45,7 +46,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { method(interp, xml, "settings", settings, 0)?; method(interp, xml, "setSettings", set_settings, 1)?; method(interp, xml, "defaultSettings", default_settings, 0)?; - let xml_methods: [(&'static str, NativeFn, u32); 39] = [ + let xml_methods: [(&'static str, NativeFn, u32); 40] = [ ("addNamespace", add_namespace, 1), ("appendChild", append_child, 1), ("attribute", attribute, 1), @@ -85,6 +86,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in xml_methods { method(interp, xml_proto, name, f, n)?; @@ -92,7 +94,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { let list = native(interp, "XMLList", xml_list_ctor, 1, NativeKind::Plain, true)?; constructor(interp, "XMLList", list, list_proto)?; - let list_methods: [(&'static str, NativeFn, u32); 21] = [ + let list_methods: [(&'static str, NativeFn, u32); 22] = [ ("attribute", attribute, 1), ("attributes", attributes, 0), ("child", child, 1), @@ -114,6 +116,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in list_methods { method(interp, list_proto, name, f, n)?; @@ -665,7 +668,7 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> /// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit /// of fuel for each node it climbs through and for each declaration it reads. -fn scope_of(interp: &mut Interp, x: Option<ObjId>) -> JsResult<Vec<XmlNs>> { +pub(crate) fn scope_of(interp: &mut Interp, x: Option<ObjId>) -> JsResult<Vec<XmlNs>> { let mut out: Vec<XmlNs> = Vec::new(); let mut seen: HashSet<Option<Key>> = HashSet::new(); let mut cur = x; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index cfe508a483..a25d867462 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -372,7 +372,7 @@ fn the_xml_methods_behave_as_specified() { assert_eq!(string("(<a>1</a>).toString()"), "1"); assert_eq!(string("(<a><b/></a>).toString()"), "<a>\n <b/>\n</a>"); truth("var x = <a/>; x.valueOf() === x"); - truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); + truth("typeof XML.prototype.xpath === 'function' && typeof XMLList.prototype.xpath === 'function' && XML.prototype.xpath.length === 1"); truth("var x = <a><b/></a>; x.b.attributes !== undefined"); } @@ -1090,6 +1090,471 @@ fn an_interrupt_stops_serialization() { assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); } +// ---- xpath ---- + +/// A script function that joins the local names of a list's items. +const NAMES: &str = "function names(l) { var s = ''; for each (var n in l) s += n.localName(); return s; }"; + +#[test] +fn xpath_selects_child_attribute_and_text_nodes() { + for check in [ + "xml.xpath('book').length() === 4", + "xml.xpath('book[2]/title') == 'Oz'", + "xml.xpath('book[last()]/title') == 'Opera'", + "xml.xpath('book[position() < 3]').length() === 2", + "xml.xpath('book[year > 1890]/title').length() === 3", + "xml.xpath('book[count(author) > 1]/title') == 'Alice'", + "xml.xpath('book[@category = \"CHILDREN\"]/title').length() === 2", + "xml.xpath('//author').length() === 6", + "xml.xpath('book/@category').length() === 4 && xml.xpath('book/@category')[0].nodeKind() === 'attribute'", + "xml.xpath('book[1]/title/text()')[0].nodeKind() === 'text' && xml.xpath('book[1]/title/text()') == 'Cookbook'", + "xml.xpath('book[1]/title/@lang') == 'en'", + "xml.xpath('nothing').length() === 0", + "xml.xpath('child::book[3]/child::author[2]') == 'B'", + "xml.xpath('*').length() === 4 && xml.xpath('book/node()').length() === 14", + ] { + truth(&format!("{BOOKS} {check}")); + } +} + +#[test] +fn xpath_returns_the_nodes_of_the_tree() { + truth(&format!("{BOOKS} xml.book[1].title[0].xpath('..')[0] === xml.book[1]")); + truth(&format!("{BOOKS} xml.xpath('book[2]')[0] === xml.book[1] && xml.xpath('.')[0] === xml")); + truth(&format!("{BOOKS} xml.xpath('book[1]/year')[0].setChildren('2000'); xml.book[0].year == '2000'")); + truth(&format!("{BOOKS} xml.xpath('book/@category')[1].setName('kind'); xml.book[1].@kind == 'CHILDREN'")); + truth("var x = <r><a>t</a></r>; x.a.text()[0].xpath('..')[0] === x.a[0]"); +} + +#[test] +fn xpath_absolute_paths_start_above_the_topmost_element() { + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/bookstore/book').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/*')[0] === xml")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('//title').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/title').length() === 0")); + truth("var d = <d><e/></d>; d.e[0].xpath('/d')[0] === d"); + for src in ["(<r/>).xpath('/')", "(<r/>).xpath('..')", "(<r/>).xpath('ancestor-or-self::node()')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_walks_every_axis() { + let tree = "var x = <r><a><b/><c/></a><d><e/></d><f/></r>;"; + for (expr, want) in [ + ("x.a[0].xpath('following-sibling::*')", "df"), + ("x.f[0].xpath('preceding-sibling::*')", "ad"), + ("x.f[0].xpath('preceding-sibling::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor::*')", "rd"), + ("x.d.e[0].xpath('ancestor::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor-or-self::*')", "rde"), + ("x.a.b[0].xpath('following::*')", "cdef"), + ("x.d.e[0].xpath('preceding::*')", "abc"), + ("x.d.e[0].xpath('preceding::*[1]')", "c"), + ("x.xpath('descendant::*')", "abcdef"), + ("x.xpath('descendant-or-self::*')", "rabcdef"), + ("x.a[0].xpath('self::a')", "a"), + ("x.a[0].xpath('self::b')", ""), + ("x.a.b[0].xpath('parent::*')", "a"), + ("x.xpath('//*[2]')", "cd"), + ("x.xpath('(//*)[2]')", "a"), + ("x.xpath('d/e/../preceding-sibling::*/c')", "c"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + let attr = "var x = <r><a p='1'><b/></a><c/></r>;"; + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('following::*'))")), "bc"); + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('ancestor::*'))")), "ra"); + truth(&format!("{attr} x.a.@p[0].xpath('following-sibling::node()').length() === 0 && x.a.@p[0].xpath('preceding::*').length() === 0")); +} + +#[test] +fn xpath_results_are_in_document_order_without_repeats() { + let tree = "var x = <r p='1'><a><b/></a><b/><c/></r>;"; + for (expr, want) in [ + ("x.xpath('c | a | //b')", "abbc"), + ("x.xpath('a | a')", "a"), + ("x.xpath('//b/..')", "ra"), + ("x.xpath('//*/ancestor::*')", "ra"), + ("x.c[0].xpath('preceding::*[position() < 3]')", "bb"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + truth(&format!("{tree} x.xpath('c | @p')[0].nodeKind() === 'attribute' && x.xpath('c | @p').length() === 2")); +} + +#[test] +fn xpath_names_match_by_namespace_uri() { + let tree = "var x = <r xmlns:p='urn:p'><p:a/><a/><q:a xmlns:q='urn:p'/><s:a xmlns:s='urn:s'/></r>;"; + for check in [ + "x.xpath('p:a').length() === 2", + "x.xpath('a').length() === 1 && x.xpath('a')[0].name().uri === ''", + "x.xpath('p:*').length() === 2", + "x.xpath('*').length() === 4", + "x.xpath('*[local-name() = \"a\"]').length() === 4", + "x.xpath('*[namespace-uri() = \"urn:s\"]').length() === 1", + "x.xpath('*[name() = \"p:a\"]').length() === 1", + ] { + truth(&format!("{tree} {check}")); + } + truth("var x = <r xmlns='urn:d'><a/></r>; x.xpath('a').length() === 0 && x.xpath('*').length() === 1"); + truth("var x = <r><a/></r>; default xml namespace = 'urn:d'; x.xpath('a').length() === 1"); + truth("var x = <r><a xml:lang='en'/></r>; x.xpath('a/@xml:lang') == 'en'"); + let rebound = "var x = <r xmlns:p='urn:1'><p:a/><b xmlns:p='urn:2'><p:a/></b></r>;"; + truth(&format!("{rebound} x.xpath('//p:a').length() === 1 && x.xpath('//p:a')[0].parent() === x")); + truth(&format!("{rebound} x.b[0].xpath('p:a').length() === 1 && x.b[0].xpath('/r/p:a').length() === 0")); + assert_eq!(thrown_name("(<r/>).xpath('z:a')"), "TypeError"); +} + +#[test] +fn xpath_namespace_nodes_can_be_tested_but_not_returned() { + let tree = "var x = <r xmlns:p='urn:p'><a xmlns:q='urn:q'/></r>;"; + truth(&format!("{tree} x.xpath('self::*[namespace::xml]').length() === 1")); + truth(&format!("{tree} x.xpath('a[count(namespace::*) = 3]').length() === 1")); + truth(&format!("{tree} x.xpath('a[namespace::q = \"urn:q\"]').length() === 1 && x.xpath('self::*[namespace::q]').length() === 0")); + truth(&format!("{tree} x.xpath('a[namespace::*[name() = \"p\"]/.. = .]').length() === 1")); + assert_eq!(thrown_name(&format!("{tree} x.xpath('namespace::*')")), "TypeError"); +} + +#[test] +fn xpath_variables_come_from_the_object_properties() { + truth(&format!("{BOOKS} xml.xpath('book[title = $t]/author', {{ t: 'Oz' }}) == 'Baum'")); + truth(&format!("{BOOKS} xml.xpath('book[$n]/title', {{ n: 3 }}) == 'Alice'")); + truth(&format!("{BOOKS} xml.xpath('book[$yes]', {{ yes: true }}).length() === 4 && xml.xpath('book[$no]', {{ no: false }}).length() === 0")); + truth(&format!("{BOOKS} xml.xpath('$b/title', {{ b: xml.book[1] }}) == 'Oz'")); + truth(&format!("{BOOKS} xml.xpath('$l/year', {{ l: xml.book.(@category == 'CHILDREN') }}).length() === 2")); + truth(&format!("{BOOKS} xml.xpath('book[year = $y]/title', {{ y: {{ toString: function () {{ return '1922'; }} }} }}) == 'Opera'")); + truth(&format!( + "{BOOKS} var o = {{}}; Object.defineProperty(o, 'v', {{ get: function () {{ return 'Alice'; }} }}); xml.xpath('book[title = $v]', o).length() === 1" + )); + for src in + ["(<r/>).xpath('$v')", "(<r/>).xpath('$v', {})", "(<r/>).xpath('$v', { v: undefined })", "(<r/>).xpath('a', 5)", "(<r/>).xpath('a', null)"] + { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_keeps_the_items_of_a_list_variable_that_a_later_getter_empties() { + let setup = "var l = <><a><b/></a></>;"; + let action = "var o = { get v() { return l; }, get w() { delete l[0]; l = null; squeeze(100000000); return 1; } }; + var r = (<r/>).xpath('$v/b | $v[$w]', o); r.length() === 2 && r[0].localName() === 'a' && r[1].localName() === 'b'"; + assert_eq!(after_setup(setup, action), Ok(JsValue::Bool(true))); +} + +#[test] +fn xpath_functions_and_operators_follow_xpath_1() { + let tree = "var x = <r xml:lang='en-US'><a>1</a><a>2</a><b>x y</b><n>12.5</n></r>;"; + let checks = [ + "last() = 1 and position() = 1", + "count(a) = 2", + "count(id('a')) = 0", + "local-name() = 'r' and name(a) = 'a' and namespace-uri() = ''", + "local-name(nothing) = '' and name(@xml:lang) = 'xml:lang'", + "string(a) = '1' and string() = '12x y12.5' and string(nothing) = ''", + "concat('a', 'b', 'c') = 'abc'", + "starts-with('abc', 'ab') and not(starts-with('abc', 'b'))", + "contains('abc', 'bc') and contains('abc', '') and not(contains('abc', 'd'))", + "substring-before('1999/04/01', '/') = '1999' and substring-before('abc', 'd') = ''", + "substring-after('1999/04/01', '/') = '04/01' and substring-after('abc', '') = 'abc'", + "substring('12345', 2, 3) = '234' and substring('12345', 2) = '2345'", + "substring('12345', 1.5, 2.6) = '234' and substring('12345', 0, 3) = '12'", + "substring('12345', 0 div 0, 3) = '' and substring('12345', 1, 0 div 0) = ''", + "substring('12345', -42, 1 div 0) = '12345' and substring('12345', -1 div 0, 1 div 0) = ''", + "string-length('abc') = 3 and string-length(b) = 4 and string-length() = 10", + "normalize-space(b) = 'x y' and normalize-space(' x \t y ') = 'x y' and normalize-space(' ') = ''", + "translate('bar', 'abc', 'ABC') = 'BAr' and translate('--aaa--', 'abc-', 'ABC') = 'AAA'", + "boolean(a) and not(boolean(nothing)) and boolean('x') and not(boolean('')) and not(boolean(0)) and not(boolean(0 div 0))", + "true() and not(false())", + "lang('en') and lang('EN-us') and not(lang('fr')) and a[lang('en')]", + "number(n) = 12.5 and number(' -3 ') = -3 and string(number('1e3')) = 'NaN' and string(number('+1')) = 'NaN'", + "sum(a) = 3 and sum(nothing) = 0", + "floor(2.5) = 2 and ceiling(2.1) = 3 and floor(-2.5) = -3", + "round(2.5) = 3 and round(-2.5) = -2 and round(2.4) = 2 and string(round(-0.4)) = '0'", + "string(1 div 0) = 'Infinity' and string(-1 div 0) = '-Infinity' and string(0 div 0) = 'NaN'", + "string(1.5) = '1.5' and string(2) = '2' and string(-0.25) = '-0.25' and string(1 div 3) = '0.3333333333333333'", + "string(1000000 * 1000000 * 1000000 * 1000) = '1000000000000000000000'", + "7 mod 3 = 1 and -7 mod 3 = -1 and 5 div 2 = 2.5 and 2 - 3 = -1 and --1 = 1 and -(1 + 1) = -2", + "1 + 2 * 3 = 7 and (1 + 2) * 3 = 9 and 3 > 2 > 1 = false()", + "a = 2 and a = '1' and a != 1 and a > 1 and not(a > 2) and a < 2 and 1 < a and 2 >= a", + "a = a and a != a and not(a[1] != a[1]) and not(nothing = nothing) and not(nothing != a) and a < a", + "true() = a and false() = nothing and '1' = 1 and true() = 'x' and 1 = true() and not(0 = true())", + "a > false() and not(a > true()) and nothing < true() and not(nothing >= true())", + "1 < 2 and 2 > 1 or false()", + "false() and 1 div 0 or true()", + "'a' < 'b' = false() and '1' < '2'", + "*[2] = 2 and *[last()] = 12.5", + "(a | b)[2] = 2", + ]; + for check in checks { + let expr = format!("self::*[{check}]"); + truth(&format!("{tree} x.xpath({expr:?}).length() === 1")); + } +} + +#[test] +fn xpath_throws_a_type_error_for_a_value_that_is_not_a_node_set() { + for expr in ["count(book)", "string(book)", "1", "'a'", "true()", "book = 1", "-book"] { + let src = format!("{BOOKS} try {{ xml.xpath({expr:?}); 'no error' }} catch (e) {{ e.name + ': ' + e.message }}"); + assert_eq!(string(&src), "TypeError: the XPath expression does not evaluate to a node-set", "{expr}"); + } +} + +#[test] +fn xpath_errors_name_their_cause() { + for expr in [ + "", + " ", + "/[1]", + "book[", + "book]", + "(", + ")", + "1 +", + "book/", + "@", + "foo::bar", + ".[1]", + "..[1]", + "'abc", + "$", + "book book", + "!", + "a//", + ",", + "count(", + "count(1,", + "a::", + "::a", + "a:", + "1..2", + "book[1]]", + "count(book))", + "//", + "/ /a", + "(1)(2)", + "processing-instruction(1)", + "node(1)", + "*:a", + "a:b:c", + ] { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "SyntaxError", "{expr:?}"); + } + for expr in + ["foo()", "count()", "count(1)", "p:count(book)", "sum('1')", "1 | book", "'a'/b", "'a'[1]", "concat('a')", "name(1)", "substring('a')"] + { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "TypeError", "{expr:?}"); + } + for src in ["(<r/>).xpath()", "(<r/>).xpath(5)", "(<r/>).xpath(new String('a'))", "XML.prototype.xpath.call({}, 'a')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_on_an_xml_list_runs_on_each_element_in_turn() { + truth( + "var l = <><a><b>1</b></a><a><b>2</b><b>3</b></a></>; l.xpath('b').length() === 3 && l.xpath('b').toXMLString() === '<b>1</b>\\n<b>2</b>\\n<b>3</b>'", + ); + truth("var l = <><a><b>1</b></a><a><b>2</b><b>3</b></a></>; l.xpath('b[1]').length() === 2"); + truth("var x = <r><a/><b/></r>; var l = x.*; var p = l.xpath('..'); p.length() === 2 && p[0] === x && p[1] === x"); + truth("var x = <r><a>t</a></r>; var l = x.a.text() + x.a; l.xpath('.').length() === 1 && l.xpath('.')[0] === x.a[0]"); + truth("var x = <r p='1'><!--c--><a/></r>; (x.@p + x.comments()).xpath('.').length() === 0"); + truth("new XMLList().xpath('a').length() === 0 && new XMLList().xpath('z:a').length() === 0"); + truth("var l = <><a xmlns:p='urn:1'><p:b/></a><a xmlns:p='urn:2'><p:b/></a></>; l.xpath('p:b').length() === 2"); + assert_eq!(thrown_name("var l = <><a xmlns:p='urn:1'/><a/></>; l.xpath('p:b')"), "TypeError"); +} + +#[test] +fn xpath_expressions_of_any_depth_use_no_native_stack() { + let deep = format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000)); + assert_eq!(thrown_name(&format!("(<r/>).xpath('{deep}')")), "SyntaxError"); + let predicates = format!("{}{}", "a[".repeat(100_000), "]".repeat(100_000)); + assert_eq!(thrown_name(&format!("(<r/>).xpath('{predicates}')")), "SyntaxError"); + let sum = format!("1{}", " + 1".repeat(100_000)); + truth(&format!("(<r/>).xpath('self::*[{sum} = 100001]').length() === 1")); + let negated = format!("{}1", "-".repeat(100_001)); + truth(&format!("(<r/>).xpath('self::*[{negated} = -1]').length() === 1")); + let union = format!("a{}", " | a".repeat(50_000)); + truth(&format!("(<r><a/></r>).xpath('{union}').length() === 1")); + let mut shallow = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + let seven = format!("{}1{}", "(".repeat(6), ")".repeat(6)); + assert_eq!(shallow.run(&format!("(<r/>).xpath('self::*[{seven}]').length()")), Ok(JsValue::Number(1.0))); + let eight = format!("{}1{}", "(".repeat(8), ")".repeat(8)); + assert_eq!(shallow.run(&format!("try {{ (<r/>).xpath('{eight}') }} catch (e) {{ e.message }}")), Ok(JsValue::String("nesting too deep".into()))); +} + +#[test] +fn xpath_stops_at_the_fuel_and_heap_limits_leaving_the_tree() { + let setup = "var x = <r/>; for (var i = 0; i < 6; i++) x.appendChild(<a><b>{i}</b></a>);"; + let check = "x.a.length() === 6 && x.a[5].b == '5' && x.*.length() === 6"; + assert!(fuel_stops(setup, "x.xpath('//b[. > 2]/preceding::*')", check) > 0); + assert!(heap_stops(setup, "x.xpath((SPARE, '//b[contains(concat(., ., .), \"3\")] | //a'))", check) > 0); + let mut r = realm(); + r.run("var x = <r/>; for (var i = 0; i < 500; i++) x.appendChild(<a/>);").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +#[test] +fn xpath_walks_of_deep_trees_and_long_lists_count_against_the_heap_limit() { + let deep = "var s = ''; for (var i = 0; i < 5000; i++) s += '<a>'; s += '<b/>'; for (var i = 0; i < 5000; i++) s += '</a>'; var x = new XML(s);"; + for (action, room) in [ + ("x.xpath('descendant::c').length()", JsValue::Number(0.0)), + ("x.xpath('self::*[string() = \"q\"]').length()", JsValue::Number(0.0)), + ("x.xpath('descendant::b | self::a').length()", JsValue::Number(2.0)), + ] { + for (spare, want) in [(20_000, Err(RunErrorKind::Limit(LimitKind::Heap))), (50_000_000, Ok(room.clone()))] { + let mut r = Realm::with_options(Options { max_depth: 1_000_000, ..e4x() }, Limits::default()); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(deep).unwrap(); + assert_eq!(r.run(&format!("squeeze({spare}); {action}")).map_err(|e| e.kind), want, "{action} {spare}"); + } + } + let long = "var s = '<r>'; for (var i = 0; i < 20000; i++) s += '<a/>'; var l = new XML(s + '</r>').a;"; + assert_eq!(after_setup(long, "squeeze(50000); l.xpath('self::c').length()"), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(long, "squeeze(50000000); l.xpath('self::c').length()"), Ok(JsValue::Number(0.0))); +} + +#[test] +fn xpath_reading_a_long_string_takes_fuel_for_its_length() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var x = <r/>; for (var i = 0; i < 200; i++) x.appendChild(<a>y</a>);"; + for expr in ["a[string-length($s) = 1]", "a[starts-with($s, $s) = false()]", "a[$s = concat('', 'x')]", "a[number($s) = 1]", "a[. = $s]"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("x.xpath({expr:?}, {{ s: s }}).length()"); + assert_eq!(r.run(&src), Ok(JsValue::Number(0.0)), "{expr}"); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_string_functions_hold_the_bytes_they_build() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var d = '1'; for (var i = 0; i < 20; i++) d += d; var o = { s: s, d: d };"; + for (expr, spare) in [("translate('a', 'b', $s) = 'a'", 1_000_000), ("number($d) > 1", 500_000), ("substring($s, 1) != 'z'", 4_000_000)] { + let action = |spare: u32| format!("squeeze({spare}); (<r/>).xpath(\"self::*[{expr}]\", o).length()"); + assert_eq!(after_setup(setup, &action(spare)), Err(RunErrorKind::Limit(LimitKind::Heap)), "{expr}"); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0)), "{expr}"); + } +} + +#[test] +fn xpath_string_variables_hold_the_bytes_of_their_flat_copy() { + // The setup ends with `0` so that its completion value does not flatten `s`. + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var o = { s: s }; 0;"; + let action = |spare: u32| format!("squeeze({spare}); (<r/>).xpath('self::*[$s]', o).length()"); + assert_eq!(after_setup(setup, &action(1_000_000)), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0))); +} + +#[test] +fn xpath_substring_searches_take_fuel_for_every_position_tried() { + let setup = "var h = 'a'; for (var i = 0; i < 16; i++) h += h; var n = h.substring(0, 4095) + 'b'; var o = { h: h, n: n };"; + for expr in ["contains($h, $n)", "substring-before($h, $n) = ''", "substring-after($h, $n) = ''"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("(<r/>).xpath(\"self::*[{expr}]\", o).length()"); + let want = if expr.starts_with("contains") { 0.0 } else { 1.0 }; + assert_eq!(r.run(&src), Ok(JsValue::Number(want)), "{expr}"); + r.set_limits(Limits { fuel: 5_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_lang_takes_fuel_for_every_attribute_it_reads() { + let setup = "var s = '<r'; for (var i = 0; i < 10000; i++) s += ' a' + i + '=\"\"'; s += '>'; \ + for (var i = 0; i < 400; i++) s += '<a/>'; var x = new XML(s + '</r>');"; + let mut r = realm(); + r.run(setup).unwrap(); + let src = "x.xpath('a[lang(\"en\")]').length()"; + assert_eq!(r.run(src), Ok(JsValue::Number(0.0))); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +#[test] +fn xpath_on_a_large_tree_ends_within_the_default_limits() { + let setup = "var x = <r/>; for (var i = 0; i < 3000; i++) x.appendChild(<a n={i}><b/></a>);"; + truth(&format!("{setup} x.xpath('//a[@n = 2999]/b').length() === 1 && x.xpath('//b/..').length() === 3000")); + let mut r = realm(); + r.run("var x = <r/>; for (var i = 0; i < 2000; i++) x.appendChild(<a/>);").unwrap(); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run("x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +const XPATH_PIECES: &[&str] = &[ + "/", + "//", + "a", + "b", + "*", + "@", + "@*", + ".", + "..", + "[", + "]", + "(", + ")", + "1", + "'x'", + "$v", + "|", + "+", + "-", + "=", + "!=", + "<", + ">=", + " and ", + " or ", + " div ", + " mod ", + "child::", + "following::", + "preceding::", + "ancestor::", + "namespace::", + "node()", + "text()", + "count(", + "last()", + "position()", + "concat(", + "substring(", + ",", + "p:", + "p:*", + "xml:lang", + "::", + ":", + "'", + " ", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn random_xpath_expressions_select_nodes_or_throw(parts in proptest::collection::vec(0..XPATH_PIECES.len(), 0..30)) { + let expr: String = parts.iter().map(|&i| XPATH_PIECES[i]).collect(); + let quoted = format!("{expr:?}"); + let src = format!("var x = <r xmlns:p='urn:p' xml:lang='en'><a p:q='1'><b>2</b><!--c--></a><p:b>3</p:b>t</r>; typeof XML.prototype.xpath === 'function' && x.a[0].xpath({quoted}, {{ v: 1 }}).length() >= 0"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", expr), + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Thrown | RunErrorKind::Limit(_)), "{expr:?}: {e:?}"), + } + } +} + // ---- generated input ---- const PIECES: &[&str] = &[ diff --git a/crates/js/src/xpath.rs b/crates/js/src/xpath.rs new file mode 100644 index 0000000000..30707e4de0 --- /dev/null +++ b/crates/js/src/xpath.rs @@ -0,0 +1,2377 @@ +//! `xpath` on XML and XMLList values: XPath 1.0 expressions +//! (<https://www.w3.org/TR/1999/REC-xpath-19991116/>) evaluated on E4X trees. +//! +//! An expression is read into tokens, then into a syntax tree whose nodes live in one arena, and is +//! evaluated with an explicit task stack, so neither step uses native recursion. Brackets nest at +//! most [`crate::Options::max_depth`] deep. Every token, task and visited node takes fuel, and the +//! token, tree, node-set and string buffers are held against the heap limit while they live. +//! +//! `/` is a root node above the topmost node of the context node's tree. A node-set result is +//! returned as an XMLList of the selected nodes themselves, in document order with attributes +//! before children. The root node and namespace nodes can be selected and tested inside an +//! expression, but a result that holds one throws a `TypeError`, since E4X has no such value. The +//! tree keeps no DTD, so `id()` selects nothing. Prefixes in the expression are read from the +//! namespaces in scope on the context element, and `xml` is always bound. + +use std::collections::{HashMap, HashSet}; +use std::mem::replace; +use std::rc::Rc; + +use crate::builtins::arg; +use crate::error::TOO_DEEP; +use crate::heap::{Key, ObjId, XmlKind, XmlName, XmlNs, key}; +use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; +use crate::regexp_vm::use_fuel_n; +use crate::string_builtins::{self, charge_units, flat_charged, push_held}; +use crate::value::Value; +use crate::xml::{Xv, is_name_part, is_name_start, is_xml_space, valid_xml_units}; +use crate::xml_builtins::scope_of; + +const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; +const COLON: u16 = b':' as u16; +const STAR: u16 = b'*' as u16; +const SLASH: u16 = b'/' as u16; +const DOT: u16 = b'.' as u16; +const EQUALS: u16 = b'=' as u16; +const DASH: u16 = b'-' as u16; +/// The estimated bytes of a syntax tree node's own lists, beyond its slot in the arena. +const EXPR_BYTES: usize = 64; +/// The estimated bytes of one entry of a hash map or set the evaluator keeps. +const ENTRY_BYTES: usize = 64; + +fn empty() -> Key { + Rc::from(Vec::new()) +} + +fn syntax(interp: &mut Interp, message: &str) -> Abrupt { + interp.throw(ErrorType::Syntax, message) +} + +fn internal() -> Abrupt { + Abrupt::Internal("XPath evaluation state".into()) +} + +fn is_digit(c: u16) -> bool { + (0x30..=0x39).contains(&c) +} + +// ---- tokens ---- + +#[derive(Clone, Copy, PartialEq)] +enum Punct { + LParen, + RParen, + LBracket, + RBracket, + Dot, + DotDot, + At, + Comma, + ColonColon, + Slash, + SlashSlash, + Pipe, + Plus, + Minus, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +/// A token as the lexer reads it, before names and `*` are told apart by their position. +enum Raw { + Punct(Punct), + Star, + Name(Option<Key>, Key), + PrefixStar(Key), + Literal(Key), + Number(f64), + Var(Key), +} + +#[derive(Clone, Copy, PartialEq)] +enum Axis { + Ancestor, + AncestorOrSelf, + Attribute, + Child, + Descendant, + DescendantOrSelf, + Following, + FollowingSibling, + Namespace, + Parent, + Preceding, + PrecedingSibling, + Itself, +} + +const AXES: [(&str, Axis); 13] = [ + ("ancestor", Axis::Ancestor), + ("ancestor-or-self", Axis::AncestorOrSelf), + ("attribute", Axis::Attribute), + ("child", Axis::Child), + ("descendant", Axis::Descendant), + ("descendant-or-self", Axis::DescendantOrSelf), + ("following", Axis::Following), + ("following-sibling", Axis::FollowingSibling), + ("namespace", Axis::Namespace), + ("parent", Axis::Parent), + ("preceding", Axis::Preceding), + ("preceding-sibling", Axis::PrecedingSibling), + ("self", Axis::Itself), +]; + +impl Axis { + /// Whether the axis lists its nodes in reverse document order. + fn reverse(self) -> bool { + matches!(self, Axis::Ancestor | Axis::AncestorOrSelf | Axis::Preceding | Axis::PrecedingSibling) + } +} + +/// A node test. A prefix is resolved when the expression runs. +#[derive(Clone)] +enum Test { + Any, + Prefix(Key), + Name(Option<Key>, Key), + Node, + Text, + Comment, + Pi(Option<Key>), +} + +#[derive(Clone, Copy, PartialEq)] +enum Bin { + Or, + And, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + Add, + Sub, + Mul, + Div, + Mod, + Union, +} + +impl Bin { + fn precedence(self) -> u8 { + match self { + Bin::Or => 1, + Bin::And => 2, + Bin::Eq | Bin::Ne => 3, + Bin::Lt | Bin::Le | Bin::Gt | Bin::Ge => 4, + Bin::Add | Bin::Sub => 5, + Bin::Mul | Bin::Div | Bin::Mod => 6, + Bin::Union => 8, + } + } +} + +/// The precedence of unary minus: tighter than `*`, looser than `|`. +const NEG_PRECEDENCE: u8 = 7; + +#[derive(Clone, Copy, PartialEq)] +enum Func { + Last, + Position, + Count, + Id, + LocalName, + NamespaceUri, + Name, + String, + Concat, + StartsWith, + Contains, + SubstringBefore, + SubstringAfter, + Substring, + StringLength, + NormalizeSpace, + Translate, + Boolean, + Not, + True, + False, + Lang, + Number, + Sum, + Floor, + Ceiling, + Round, +} + +/// The core function library (XPath 1.0 section 4): each name, and its least and most arguments. +const FUNCS: [(&str, Func, usize, usize); 27] = [ + ("last", Func::Last, 0, 0), + ("position", Func::Position, 0, 0), + ("count", Func::Count, 1, 1), + ("id", Func::Id, 1, 1), + ("local-name", Func::LocalName, 0, 1), + ("namespace-uri", Func::NamespaceUri, 0, 1), + ("name", Func::Name, 0, 1), + ("string", Func::String, 0, 1), + ("concat", Func::Concat, 2, usize::MAX), + ("starts-with", Func::StartsWith, 2, 2), + ("contains", Func::Contains, 2, 2), + ("substring-before", Func::SubstringBefore, 2, 2), + ("substring-after", Func::SubstringAfter, 2, 2), + ("substring", Func::Substring, 2, 3), + ("string-length", Func::StringLength, 0, 1), + ("normalize-space", Func::NormalizeSpace, 0, 1), + ("translate", Func::Translate, 3, 3), + ("boolean", Func::Boolean, 1, 1), + ("not", Func::Not, 1, 1), + ("true", Func::True, 0, 0), + ("false", Func::False, 0, 0), + ("lang", Func::Lang, 1, 1), + ("number", Func::Number, 0, 1), + ("sum", Func::Sum, 1, 1), + ("floor", Func::Floor, 1, 1), + ("ceiling", Func::Ceiling, 1, 1), + ("round", Func::Round, 1, 1), +]; + +/// A token the parser reads: names and `*` with their roles decided (XPath 1.0 section 3.7). +enum Tok { + Punct(Punct), + Op(Bin), + Test(Test), + Axis(Axis), + Func(Func, &'static str), + Literal(Key), + Number(f64), + Var(Key), +} + +fn ncname_end(src: &[u16], at: usize) -> Option<usize> { + let first = *src.get(at)?; + if first == COLON || !is_name_start(first) { + return None; + } + let mut end = at + 1; + while src.get(end).is_some_and(|&c| c != COLON && is_name_part(c)) { + end += 1; + } + valid_xml_units(src.get(at..end)?).then_some(end) +} + +fn lex_name(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let end = ncname_end(src, at)?; + let first: Key = Rc::from(src.get(at..end)?); + if src.get(end) != Some(&COLON) || src.get(end + 1) == Some(&COLON) { + return Some((Raw::Name(None, first), end)); + } + if src.get(end + 1) == Some(&STAR) { + return Some((Raw::PrefixStar(first), end + 2)); + } + let local_end = ncname_end(src, end + 1)?; + Some((Raw::Name(Some(first), Rc::from(src.get(end + 1..local_end)?)), local_end)) +} + +fn lex_number(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let mut end = at; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + if src.get(end) == Some(&DOT) { + end += 1; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + } + let text = String::from_utf16(src.get(at..end)?).ok()?; + Some((Raw::Number(text.parse().ok()?), end)) +} + +fn lex_literal(src: &[u16], at: usize, quote: u16) -> Option<(Raw, usize)> { + let rest = src.get(at + 1..)?; + let len = rest.iter().position(|&c| c == quote)?; + Some((Raw::Literal(Rc::from(rest.get(..len)?)), at + len + 2)) +} + +fn lex_var(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let (Raw::Name(..), end) = lex_name(src, at + 1)? else { return None }; + Some((Raw::Var(Rc::from(src.get(at + 1..end)?)), end)) +} + +/// The token that starts at `src[at]`, and the offset after it. +fn lex_one(src: &[u16], at: usize, c: u16) -> Option<(Raw, usize)> { + if c != COLON && is_name_start(c) { + return lex_name(src, at); + } + let next = src.get(at + 1).copied(); + let p = |p: Punct, n: usize| Some((Raw::Punct(p), at + n)); + match char::from_u32(u32::from(c))? { + '(' => p(Punct::LParen, 1), + ')' => p(Punct::RParen, 1), + '[' => p(Punct::LBracket, 1), + ']' => p(Punct::RBracket, 1), + '@' => p(Punct::At, 1), + ',' => p(Punct::Comma, 1), + '|' => p(Punct::Pipe, 1), + '+' => p(Punct::Plus, 1), + '-' => p(Punct::Minus, 1), + '=' => p(Punct::Eq, 1), + '*' => Some((Raw::Star, at + 1)), + '/' if next == Some(SLASH) => p(Punct::SlashSlash, 2), + '/' => p(Punct::Slash, 1), + ':' if next == Some(COLON) => p(Punct::ColonColon, 2), + '!' if next == Some(EQUALS) => p(Punct::Ne, 2), + '<' if next == Some(EQUALS) => p(Punct::Le, 2), + '<' => p(Punct::Lt, 1), + '>' if next == Some(EQUALS) => p(Punct::Ge, 2), + '>' => p(Punct::Gt, 1), + '.' if next == Some(DOT) => p(Punct::DotDot, 2), + '.' if next.is_some_and(is_digit) => lex_number(src, at), + '.' => p(Punct::Dot, 1), + '"' | '\'' => lex_literal(src, at, c), + '$' => lex_var(src, at), + _ if is_digit(c) => lex_number(src, at), + _ => None, + } +} + +fn lex(interp: &mut Interp, held: &mut HeldBytes, src: &[u16]) -> JsResult<Vec<Raw>> { + let mut out = Vec::new(); + let mut at = 0; + loop { + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + let Some(&c) = src.get(at) else { return Ok(out) }; + let Some((raw, end)) = lex_one(src, at, c) else { + return Err(syntax(interp, &format!("the XPath expression cannot be read at offset {at}"))); + }; + charge_units(interp, end - at)?; + push_held(interp, held, &mut out, raw)?; + at = end; + } +} + +fn units_are(units: &[u16], text: &str) -> bool { + units.iter().copied().eq(text.encode_utf16()) +} + +fn operator_name(name: &[u16]) -> Option<Bin> { + [("and", Bin::And), ("or", Bin::Or), ("mod", Bin::Mod), ("div", Bin::Div)].into_iter().find(|(n, _)| units_are(name, n)).map(|(_, b)| b) +} + +fn punct_token(p: Punct) -> Tok { + match p { + Punct::Pipe => Tok::Op(Bin::Union), + Punct::Plus => Tok::Op(Bin::Add), + Punct::Minus => Tok::Op(Bin::Sub), + Punct::Eq => Tok::Op(Bin::Eq), + Punct::Ne => Tok::Op(Bin::Ne), + Punct::Lt => Tok::Op(Bin::Lt), + Punct::Le => Tok::Op(Bin::Le), + Punct::Gt => Tok::Op(Bin::Gt), + Punct::Ge => Tok::Op(Bin::Ge), + other => Tok::Punct(other), + } +} + +/// Whether a token at this point is an operator: there is a token before it, and it is not `@`, +/// `::`, `(`, `[`, `,` or an operator (XPath 1.0 section 3.7). +fn operator_position(last: Option<&Tok>) -> bool { + last.is_some_and(|t| { + !matches!( + t, + Tok::Punct(Punct::LParen | Punct::LBracket | Punct::At | Punct::Comma | Punct::Slash | Punct::SlashSlash) + | Tok::Op(_) + | Tok::Axis(_) + | Tok::Func(..) + ) + }) +} + +/// A name followed by `(`: a node type test, which also takes its `)`, or a function call. +/// Returns the token and how many raw tokens it takes. +fn call_or_node_type(interp: &mut Interp, prefix: Option<&Key>, name: &Key, after: &[Raw]) -> JsResult<(Tok, usize)> { + if prefix.is_none() { + let plain = [("node", Test::Node), ("text", Test::Text), ("comment", Test::Comment)].into_iter().find(|(n, _)| units_are(name, n)); + if let Some((_, test)) = plain { + return match after.first() { + Some(Raw::Punct(Punct::RParen)) => Ok((Tok::Test(test), 3)), + _ => Err(syntax(interp, "an XPath node type test takes no argument")), + }; + } + if units_are(name, "processing-instruction") { + return match after { + [Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(None)), 3)), + [Raw::Literal(target), Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(Some(target.clone()))), 4)), + _ => Err(syntax(interp, "processing-instruction() takes one string literal or none")), + }; + } + } + let found = FUNCS.iter().find(|(n, ..)| prefix.is_none() && units_are(name, n)); + match found { + Some(&(n, f, ..)) => Ok((Tok::Func(f, n), 2)), + None => { + let shown = String::from_utf16_lossy(name); + Err(interp.type_error(&format!("{shown} is not an XPath 1.0 function"))) + } + } +} + +fn classify(interp: &mut Interp, held: &mut HeldBytes, raw: &[Raw]) -> JsResult<Vec<Tok>> { + let mut out: Vec<Tok> = Vec::new(); + let mut at = 0; + while let Some(r) = raw.get(at) { + interp.use_fuel()?; + let operator = operator_position(out.last()); + let next = raw.get(at + 1); + let (tok, used) = match r { + Raw::Star if operator => (Tok::Op(Bin::Mul), 1), + Raw::Star => (Tok::Test(Test::Any), 1), + Raw::Name(None, n) if operator => match operator_name(n) { + Some(op) => (Tok::Op(op), 1), + None => return Err(syntax(interp, "an XPath operator is missing")), + }, + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::LParen))) => { + call_or_node_type(interp, p.as_ref(), n, raw.get(at + 2..).unwrap_or_default())? + } + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::ColonColon))) => { + let axis = AXES.iter().find(|(a, _)| p.is_none() && units_are(n, a)).map(|&(_, a)| a); + match axis { + Some(a) => (Tok::Axis(a), 2), + None => return Err(syntax(interp, &format!("{} is not an XPath axis", String::from_utf16_lossy(n)))), + } + } + Raw::Name(p, n) => (Tok::Test(Test::Name(p.clone(), n.clone())), 1), + Raw::PrefixStar(p) => (Tok::Test(Test::Prefix(p.clone())), 1), + Raw::Punct(Punct::ColonColon) => return Err(syntax(interp, "an XPath axis name is missing before ::")), + Raw::Punct(p) => (punct_token(*p), 1), + Raw::Literal(s) => (Tok::Literal(s.clone()), 1), + Raw::Number(n) => (Tok::Number(*n), 1), + Raw::Var(v) => (Tok::Var(v.clone()), 1), + }; + push_held(interp, held, &mut out, tok)?; + at += used; + } + Ok(out) +} + +// ---- the syntax tree ---- + +struct Step { + axis: Axis, + test: Test, + preds: Vec<usize>, +} + +enum Expr { + Bin(Bin, usize, usize), + Neg(usize), + Num(f64), + Lit(Key), + Var(usize), + Call(Func, Vec<usize>), + /// A location path, or a filter expression with its predicates, followed by steps. + Path { + filter: Option<(usize, Vec<usize>)>, + absolute: bool, + steps: Vec<Step>, + }, +} + +/// A parsed expression: the tree in its arena, its root, and the variables and prefixes it names. +struct Parsed { + exprs: Vec<Expr>, + root: usize, + vars: Vec<Key>, + prefixes: Vec<Key>, + _held: HeldBytes, +} + +#[derive(Clone, Copy)] +enum Op { + Bin(Bin), + Neg, +} + +impl Op { + fn precedence(self) -> u8 { + match self { + Op::Bin(b) => b.precedence(), + Op::Neg => NEG_PRECEDENCE, + } + } +} + +/// A path expression being read. +#[derive(Default)] +struct Operand { + filter: Option<(usize, Vec<usize>)>, + absolute: bool, + steps: Vec<Step>, + /// The last step is `.` or `..`, which take no predicate. + abbreviated: bool, +} + +impl Operand { + fn bare_root(&self) -> bool { + self.absolute && self.steps.is_empty() && self.filter.is_none() + } +} + +enum FrameKind { + Top, + Group, + Pred, + Args(Func, &'static str, Vec<usize>), +} + +/// One bracket level: its operators and operands, read by operator precedence. +struct Frame { + kind: FrameKind, + ops: Vec<Op>, + vals: Vec<usize>, + operand: Option<Operand>, +} + +impl Frame { + fn new(kind: FrameKind) -> Self { + Frame { kind, ops: Vec::new(), vals: Vec::new(), operand: None } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Want { + Operand, + Step, + Test(Axis), + After, +} + +struct Parser<'i> { + interp: &'i mut Interp, + held: HeldBytes, + exprs: Vec<Expr>, + vars: Vec<Key>, + var_index: HashMap<Key, usize>, + prefixes: HashSet<Key>, + frames: Vec<Frame>, + want: Want, +} + +impl Parser<'_> { + fn error(&mut self, message: &str) -> Abrupt { + syntax(self.interp, message) + } + + fn push_expr(&mut self, e: Expr) -> JsResult<usize> { + self.held.add(self.interp, EXPR_BYTES)?; + push_held(self.interp, &mut self.held, &mut self.exprs, e)?; + Ok(self.exprs.len() - 1) + } + + fn top(&mut self) -> JsResult<&mut Frame> { + self.frames.last_mut().ok_or_else(internal) + } + + fn operand(&mut self) -> JsResult<&mut Operand> { + self.top()?.operand.as_mut().ok_or_else(internal) + } + + fn open(&mut self, kind: FrameKind) -> JsResult<()> { + if self.frames.len() >= self.interp.options.max_depth { + return Err(self.error(TOO_DEEP)); + } + self.held.add(self.interp, size_of::<Frame>())?; + self.frames.push(Frame::new(kind)); + self.want = Want::Operand; + Ok(()) + } + + fn token(&mut self, tok: Tok) -> JsResult<()> { + match self.want { + Want::Operand => self.operand_token(tok), + Want::Step => self.step_token(tok), + Want::Test(axis) => match tok { + Tok::Test(test) => self.push_step(axis, test, false), + _ => Err(self.error("an XPath node test is missing")), + }, + Want::After => self.after_token(tok), + } + } + + fn primary(&mut self, e: Expr) -> JsResult<()> { + let id = self.push_expr(e)?; + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn operand_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Op(Bin::Sub) => { + self.top()?.ops.push(Op::Neg); + Ok(()) + } + Tok::Punct(Punct::Slash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.want = Want::Step; + Ok(()) + } + Tok::Punct(Punct::SlashSlash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.descendants_step() + } + Tok::Literal(s) => self.primary(Expr::Lit(s)), + Tok::Number(n) => self.primary(Expr::Num(n)), + Tok::Var(name) => { + let next = self.vars.len(); + let at = *self.var_index.entry(name.clone()).or_insert(next); + if at == next { + push_held(self.interp, &mut self.held, &mut self.vars, name)?; + } + self.primary(Expr::Var(at)) + } + Tok::Func(f, n) => self.open(FrameKind::Args(f, n, Vec::new())), + Tok::Punct(Punct::LParen) => self.open(FrameKind::Group), + Tok::Punct(Punct::RParen) if self.empty_call()? => self.close_paren(), + tok @ (Tok::Test(_) | Tok::Axis(_) | Tok::Punct(Punct::At | Punct::Dot | Punct::DotDot)) => { + self.top()?.operand = Some(Operand::default()); + self.step_token(tok) + } + _ => Err(self.error("an XPath operand is missing")), + } + } + + /// Whether the open bracket is a function call's, with nothing in it yet. + fn empty_call(&mut self) -> JsResult<bool> { + let f = self.top()?; + Ok(matches!(&f.kind, FrameKind::Args(_, _, args) if args.is_empty()) && f.ops.is_empty() && f.vals.is_empty() && f.operand.is_none()) + } + + fn descendants_step(&mut self) -> JsResult<()> { + self.operand()?.steps.push(Step { axis: Axis::DescendantOrSelf, test: Test::Node, preds: Vec::new() }); + self.want = Want::Step; + Ok(()) + } + + fn push_step(&mut self, axis: Axis, test: Test, abbreviated: bool) -> JsResult<()> { + if let Test::Prefix(p) | Test::Name(Some(p), _) = &test + && !self.prefixes.contains(p) + { + self.held.add(self.interp, ENTRY_BYTES)?; + self.prefixes.insert(p.clone()); + } + self.held.add(self.interp, size_of::<Step>())?; + let operand = self.operand()?; + operand.steps.push(Step { axis, test, preds: Vec::new() }); + operand.abbreviated = abbreviated; + self.want = Want::After; + Ok(()) + } + + fn step_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(Punct::Dot) => self.push_step(Axis::Itself, Test::Node, true), + Tok::Punct(Punct::DotDot) => self.push_step(Axis::Parent, Test::Node, true), + Tok::Punct(Punct::At) => { + self.want = Want::Test(Axis::Attribute); + Ok(()) + } + Tok::Axis(a) => { + self.want = Want::Test(a); + Ok(()) + } + Tok::Test(t) => self.push_step(Axis::Child, t, false), + tok if self.operand()?.bare_root() => { + self.want = Want::After; + self.after_token(tok) + } + _ => Err(self.error("an XPath step is missing")), + } + } + + fn after_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(p @ (Punct::LBracket | Punct::Slash | Punct::SlashSlash)) => { + let operand = self.operand()?; + let (bare, abbreviated) = (operand.bare_root(), operand.abbreviated); + if bare || (p == Punct::LBracket && abbreviated) { + return Err(self.error("an XPath predicate or step cannot follow here")); + } + match p { + Punct::LBracket => self.open(FrameKind::Pred), + Punct::Slash => { + self.want = Want::Step; + Ok(()) + } + _ => self.descendants_step(), + } + } + Tok::Op(b) => self.binary(b), + Tok::Punct(Punct::RParen) => self.close_paren(), + Tok::Punct(Punct::RBracket) => self.close_bracket(), + Tok::Punct(Punct::Comma) => self.next_argument(), + _ => Err(self.error("an XPath operator is missing")), + } + } + + fn finish_operand(&mut self, operand: Operand) -> JsResult<usize> { + match operand { + Operand { filter: Some((e, preds)), steps, .. } if preds.is_empty() && steps.is_empty() => Ok(e), + Operand { filter, absolute, steps, .. } => self.push_expr(Expr::Path { filter, absolute, steps }), + } + } + + fn reduce_one(&mut self, frame: &mut Frame) -> JsResult<()> { + let op = frame.ops.pop().ok_or_else(internal)?; + let rhs = frame.vals.pop().ok_or_else(internal)?; + let e = match op { + Op::Neg => Expr::Neg(rhs), + Op::Bin(b) => Expr::Bin(b, frame.vals.pop().ok_or_else(internal)?, rhs), + }; + let id = self.push_expr(e)?; + frame.vals.push(id); + Ok(()) + } + + fn binary(&mut self, b: Bin) -> JsResult<()> { + let mut frame = self.frames.pop().ok_or_else(internal)?; + let result = self.binary_in(&mut frame, b); + self.frames.push(frame); + result?; + self.want = Want::Operand; + Ok(()) + } + + fn binary_in(&mut self, frame: &mut Frame, b: Bin) -> JsResult<()> { + let operand = frame.operand.take().ok_or_else(internal)?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while frame.ops.last().is_some_and(|op| op.precedence() >= b.precedence()) { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + self.held.add(self.interp, 2 * size_of::<usize>())?; + frame.ops.push(Op::Bin(b)); + Ok(()) + } + + /// Ends the innermost bracket and returns its kind and expression. + fn close(&mut self) -> JsResult<(FrameKind, usize)> { + if self.frames.len() < 2 { + return Err(self.error("the XPath expression has an unmatched closing bracket")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame)?; + Ok((frame.kind, e)) + } + + fn reduce(&mut self, frame: &mut Frame) -> JsResult<usize> { + let operand = frame.operand.take().ok_or_else(|| syntax(self.interp, "an XPath operand is missing"))?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while !frame.ops.is_empty() { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + match frame.vals.as_slice() { + [e] => Ok(*e), + _ => Err(internal()), + } + } + + fn close_paren(&mut self) -> JsResult<()> { + let (kind, e) = if self.top()?.operand.is_none() { + (self.frames.pop().ok_or_else(internal)?.kind, None) + } else { + let (kind, e) = self.close()?; + (kind, Some(e)) + }; + match kind { + FrameKind::Group => self.primary_id(e.ok_or_else(internal)?), + FrameKind::Args(f, name, mut args) => { + args.extend(e); + let (_, _, least, most) = FUNCS.iter().find(|(_, g, ..)| *g == f).copied().ok_or_else(internal)?; + if args.len() < least || args.len() > most { + return Err(self.interp.type_error(&format!("the XPath function {name} takes the wrong number of arguments"))); + } + let call = self.push_expr(Expr::Call(f, args))?; + self.primary_id(call) + } + _ => Err(self.error("the XPath expression has an unmatched )")), + } + } + + fn primary_id(&mut self, id: usize) -> JsResult<()> { + if self.frames.is_empty() { + return Err(self.error("the XPath expression has an unmatched )")); + } + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn close_bracket(&mut self) -> JsResult<()> { + let (kind, e) = self.close()?; + if !matches!(kind, FrameKind::Pred) { + return Err(self.error("the XPath expression has an unmatched ]")); + } + let operand = self.operand()?; + match (operand.steps.last_mut(), operand.filter.as_mut()) { + (Some(step), _) => step.preds.push(e), + (None, Some((_, preds))) => preds.push(e), + (None, None) => return Err(internal()), + } + self.want = Want::After; + Ok(()) + } + + fn next_argument(&mut self) -> JsResult<()> { + if !matches!(self.top()?.kind, FrameKind::Args(..)) || self.frames.len() < 2 { + return Err(self.error("a comma in an XPath expression must separate function arguments")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame); + if let (Ok(e), FrameKind::Args(_, _, args)) = (&e, &mut frame.kind) { + args.push(*e); + } + frame.ops.clear(); + frame.vals.clear(); + self.frames.push(frame); + e?; + self.want = Want::Operand; + Ok(()) + } + + fn finish(mut self) -> JsResult<Parsed> { + let complete = self.want == Want::After || (self.want == Want::Step && self.operand()?.bare_root()); + if !complete || self.frames.len() != 1 { + return Err(self.error("the XPath expression ends early")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let root = self.reduce(&mut frame)?; + Ok(Parsed { exprs: self.exprs, root, vars: self.vars, prefixes: self.prefixes.into_iter().collect(), _held: self.held }) + } +} + +fn parse(interp: &mut Interp, src: &[u16]) -> JsResult<Parsed> { + let mut held = HeldBytes::new(interp); + let raw = lex(interp, &mut held, src)?; + let toks = classify(interp, &mut held, &raw)?; + drop(raw); + let mut p = Parser { + interp, + held, + exprs: Vec::new(), + vars: Vec::new(), + var_index: HashMap::new(), + prefixes: HashSet::new(), + frames: vec![Frame::new(FrameKind::Top)], + want: Want::Operand, + }; + for tok in toks { + p.interp.use_fuel()?; + p.token(tok)?; + } + p.finish() +} + +// ---- values ---- + +/// A node of the XPath data model. `Root` is the root above the topmost node it names, and `Ns` +/// is a namespace node of `owner`, at position `at` among its namespaces. +#[derive(Clone, PartialEq, Eq, Hash)] +enum XNode { + Root(ObjId), + Node(ObjId), + Ns { owner: ObjId, at: u32, prefix: Key, uri: Key }, +} + +#[derive(Clone, Copy, PartialEq)] +enum NodeKind { + Root, + Ns, + Xml(XmlKind), +} + +/// A list of nodes whose buffer is held against the heap limit. +struct Nodes { + list: Vec<XNode>, + held: HeldBytes, +} + +impl Nodes { + fn new(interp: &Interp) -> Self { + Nodes { list: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, n: XNode) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.list, n) + } +} + +/// A string value whose units are held against the heap limit when the evaluator built them. +struct Text { + units: Key, + _held: HeldBytes, +} + +impl Text { + fn plain(interp: &Interp, units: Key) -> Self { + Text { units, _held: HeldBytes::new(interp) } + } +} + +struct TextBuf { + units: Vec<u16>, + held: HeldBytes, +} + +impl TextBuf { + fn new(interp: &Interp) -> Self { + TextBuf { units: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, more: &[u16]) -> JsResult<()> { + charge_units(interp, more.len())?; + let want = self.units.len().saturating_add(more.len()); + let cap = self.units.capacity(); + if want > cap { + let grown = want.max(cap.saturating_mul(2)).max(16); + self.held.add(interp, (grown - cap).saturating_mul(2))?; + self.units.reserve_exact(grown - self.units.len()); + } + self.units.extend_from_slice(more); + Ok(()) + } + + fn finish(self) -> Text { + Text { units: Rc::from(self.units), _held: self.held } + } +} + +enum Val { + Set(Nodes), + Bool(bool), + Num(f64), + Str(Text), +} + +/// A variable's value, read from the variables object before evaluation starts. +enum Bound { + Set(Vec<XNode>), + Bool(bool), + Num(f64), + Str(Key), +} + +#[derive(Clone)] +struct Ctx { + node: XNode, + pos: usize, + size: usize, +} + +/// A location path being evaluated. Stage 0 applies the filter expression's predicates to the +/// filter's nodes; stage `s` applies step `s - 1` to each node of `inputs` in turn. +struct PathRun { + path: usize, + stage: usize, + inputs: Nodes, + ci: usize, + cur: Nodes, + pred: usize, + at: usize, + kept: Nodes, + waiting: bool, + out: Nodes, + seen: HashSet<XNode>, + seen_held: HeldBytes, +} + +enum Task { + Eval(usize, Ctx), + Bin(Bin), + Neg, + Logic { and: bool, rhs: usize, ctx: Ctx }, + Bool, + Call(Func, usize, Ctx), + PathStart(usize), + Path(Box<PathRun>), +} + +fn to_bool(v: &Val) -> bool { + match v { + Val::Set(s) => !s.list.is_empty(), + Val::Bool(b) => *b, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(t) => !t.units.is_empty(), + } +} + +/// The number a string stands for (XPath 1.0 section 4.4): optional white space, an optional +/// minus, digits with an optional fraction, optional white space; anything else is NaN. +fn str_num(interp: &mut Interp, units: &[u16]) -> JsResult<f64> { + let start = units.iter().position(|&c| !is_xml_space(c)).unwrap_or(units.len()); + let end = units.iter().rposition(|&c| !is_xml_space(c)).map_or(start, |e| e + 1); + let body = units.get(start..end).unwrap_or_default(); + let (negative, digits) = match body.split_first() { + Some((&DASH, rest)) => (true, rest), + _ => (false, body), + }; + let dots = digits.iter().filter(|&&c| c == DOT).count(); + if dots > 1 || !digits.iter().all(|&c| c == DOT || is_digit(c)) || !digits.iter().any(|&c| is_digit(c)) { + return Ok(f64::NAN); + } + let mut held = HeldBytes::new(interp); + held.add(interp, digits.len())?; + let text: String = digits.iter().map(|&c| char::from(c as u8)).collect(); + let n: f64 = text.parse().unwrap_or(f64::NAN); + Ok(if negative { -n } else { n }) +} + +/// A number as a string (XPath 1.0 section 4.2): integers without a decimal point, and no +/// exponent. +fn num_text(n: f64) -> String { + if n.is_nan() { + "NaN".into() + } else if n.is_infinite() { + if n > 0.0 { "Infinity".into() } else { "-Infinity".into() } + } else if n == 0.0 { + "0".into() + } else { + format!("{n}") + } +} + +/// `round` (XPath 1.0 section 4.4): the nearest integer, halves toward positive infinity, with +/// negative zero for values from -0.5 up to zero. +fn round(n: f64) -> f64 { + if !n.is_finite() || n == 0.0 { + return n; + } + let floor = n.floor(); + let r = if n - floor >= 0.5 { floor + 1.0 } else { floor }; + if r == 0.0 && n < 0.0 { -0.0 } else { r } +} + +fn compare_numbers(op: Bin, a: f64, b: f64) -> bool { + match op { + Bin::Eq => a == b, + Bin::Ne => a != b, + Bin::Lt => a < b, + Bin::Le => a <= b, + Bin::Gt => a > b, + _ => a >= b, + } +} + +fn flip(op: Bin) -> Bin { + match op { + Bin::Lt => Bin::Gt, + Bin::Le => Bin::Ge, + Bin::Gt => Bin::Lt, + Bin::Ge => Bin::Le, + other => other, + } +} + +fn code_points(units: &[u16]) -> Vec<u32> { + char::decode_utf16(units.iter().copied()).map(|r| r.map_or_else(|e| u32::from(e.unpaired_surrogate()), u32::from)).collect() +} + +fn push_point(out: &mut Vec<u16>, p: u32) { + match char::from_u32(p) { + Some(c) => { + let mut buf = [0u16; 2]; + out.extend_from_slice(c.encode_utf16(&mut buf)); + } + None => out.extend(u16::try_from(p).ok()), + } +} + +fn lower(c: u16) -> u16 { + if (0x41..=0x5A).contains(&c) { c + 0x20 } else { c } +} + +fn same_ignoring_case(a: &[u16], b: &[u16]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(&x, &y)| lower(x) == lower(y)) +} + +fn name_uri(name: &XmlName) -> &[u16] { + name.uri.as_deref().unwrap_or_default() +} + +fn kind_of(interp: &Interp, n: &XNode) -> JsResult<NodeKind> { + Ok(match n { + XNode::Root(_) => NodeKind::Root, + XNode::Ns { .. } => NodeKind::Ns, + XNode::Node(x) => NodeKind::Xml(interp.xml_kind(*x)?), + }) +} + +fn parent_of(interp: &Interp, n: &XNode) -> JsResult<Option<XNode>> { + Ok(match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(interp.xml_node(*x)?.parent.map_or(XNode::Root(*x), XNode::Node)), + XNode::Ns { owner, .. } => Some(XNode::Node(*owner)), + }) +} + +/// The parent of `x` and the position of `x` among its children, when `x` is a child. +fn child_position(interp: &mut Interp, x: ObjId) -> JsResult<Option<(ObjId, usize)>> { + let node = interp.xml_node(x)?; + let Some(p) = node.parent.filter(|_| node.kind != XmlKind::Attribute) else { return Ok(None) }; + let len = interp.xml_node(p)?.children.len(); + for i in 0..len { + interp.use_fuel()?; + if interp.xml_node(p)?.children.get(i) == Some(&x) { + return Ok(Some((p, i))); + } + } + Ok(None) +} + +fn top_of(interp: &mut Interp, mut x: ObjId) -> JsResult<ObjId> { + while let Some(p) = interp.xml_node(x)?.parent { + interp.use_fuel()?; + x = p; + } + Ok(x) +} + +/// The XPath expression evaluator for one call: the parsed expression, the variables, the +/// prefixes resolved for the current context element, and the document order of the trees met. +struct Ev<'p> { + parsed: &'p Parsed, + bound: Vec<Bound>, + uris: HashMap<Key, Key>, + order: HashMap<ObjId, u64>, + root_order: HashMap<ObjId, u64>, + next: u64, + held: HeldBytes, + tasks: Vec<Task>, + vals: Vec<Val>, +} + +impl<'p> Ev<'p> { + fn new(interp: &mut Interp, parsed: &'p Parsed, bound: Vec<Bound>) -> JsResult<Self> { + let mut ev = Ev { + parsed, + bound: Vec::new(), + uris: HashMap::new(), + order: HashMap::new(), + root_order: HashMap::new(), + next: 0, + held: HeldBytes::new(interp), + tasks: Vec::new(), + vals: Vec::new(), + }; + for b in bound { + let b = match b { + Bound::Set(list) => { + let mut nodes = Nodes::new(interp); + for n in list { + nodes.push(interp, n)?; + } + let sorted = ev.sort(interp, nodes)?; + ev.held.add(interp, sorted.list.len().saturating_mul(size_of::<XNode>()))?; + Bound::Set(sorted.list) + } + other => other, + }; + ev.bound.push(b); + } + Ok(ev) + } + + // ---- document order ---- + + fn put_order(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.held.add(interp, ENTRY_BYTES)?; + self.order.insert(x, self.next); + self.next += 1; + Ok(()) + } + + /// Numbers the tree under `top` in document order: the root, then each node followed by its + /// attributes and then its children. + fn number_tree(&mut self, interp: &mut Interp, top: ObjId) -> JsResult<()> { + if self.root_order.contains_key(&top) { + return Ok(()); + } + self.held.add(interp, ENTRY_BYTES)?; + self.root_order.insert(top, self.next); + self.next += 1; + self.number_node(interp, top)?; + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (top, 0usize))?; + while let Some(&(x, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(x)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.number_node(interp, c)?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn number_node(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.put_order(interp, x)?; + let count = interp.xml_node(x)?.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + if let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() { + self.put_order(interp, a)?; + } + } + Ok(()) + } + + fn order_of(&mut self, interp: &mut Interp, n: &XNode) -> JsResult<(u64, u32)> { + match n { + XNode::Root(t) => { + self.number_tree(interp, *t)?; + Ok((self.root_order.get(t).copied().ok_or_else(internal)?, 0)) + } + XNode::Node(x) => { + if !self.order.contains_key(x) { + let top = top_of(interp, *x)?; + self.number_tree(interp, top)?; + } + Ok((self.order.get(x).copied().ok_or_else(internal)?, 0)) + } + XNode::Ns { owner, at, .. } => { + let (o, _) = self.order_of(interp, &XNode::Node(*owner))?; + Ok((o, at.saturating_add(1))) + } + } + } + + /// The nodes in document order, without repeats. + fn sort(&mut self, interp: &mut Interp, set: Nodes) -> JsResult<Nodes> { + if set.list.len() < 2 { + return Ok(set); + } + let n = set.list.len() as u64; + let mut held = HeldBytes::new(interp); + let mut keyed = Vec::new(); + for x in set.list { + let k = self.order_of(interp, &x)?; + push_held(interp, &mut held, &mut keyed, (k, x))?; + } + use_fuel_n(interp, n.saturating_mul(u64::from(n.ilog2()) + 1) / 8 + 1)?; + keyed.sort_by_key(|(k, _)| *k); + keyed.dedup_by_key(|(k, _)| *k); + let mut out = Nodes::new(interp); + for (_, x) in keyed { + out.push(interp, x)?; + } + Ok(out) + } + + // ---- axes and node tests ---- + + fn prefix_uri(&self, p: &Key) -> JsResult<&Key> { + self.uris.get(p).ok_or_else(internal) + } + + fn matches(&self, interp: &Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult<bool> { + let kind = kind_of(interp, n)?; + let principal = match axis { + Axis::Attribute => NodeKind::Xml(XmlKind::Attribute), + Axis::Namespace => NodeKind::Ns, + _ => NodeKind::Xml(XmlKind::Element), + }; + let name = match n { + XNode::Node(x) => interp.xml_node(*x)?.name.as_ref(), + _ => None, + }; + Ok(match test { + Test::Node => true, + Test::Text => kind == NodeKind::Xml(XmlKind::Text), + Test::Comment => kind == NodeKind::Xml(XmlKind::Comment), + Test::Pi(target) => kind == NodeKind::Xml(XmlKind::Pi) && target.as_ref().is_none_or(|t| name.is_some_and(|q| q.local == *t)), + Test::Any => kind == principal, + Test::Prefix(p) => { + let uri = self.prefix_uri(p)?; + kind == principal && name.is_some_and(|q| name_uri(q) == &**uri) + } + Test::Name(p, local) => kind == principal && self.name_matches(n, name, p.as_ref(), local)?, + }) + } + + fn name_matches(&self, n: &XNode, name: Option<&XmlName>, prefix: Option<&Key>, local: &Key) -> JsResult<bool> { + if let XNode::Ns { prefix: ns_prefix, .. } = n { + return Ok(prefix.is_none() && ns_prefix == local); + } + let Some(q) = name else { return Ok(false) }; + let uri: &[u16] = match prefix { + Some(p) => self.prefix_uri(p)?, + None => &[], + }; + Ok(q.local == *local && name_uri(q) == uri) + } + + fn add(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: XNode) -> JsResult<()> { + interp.use_fuel()?; + if self.matches(interp, axis, test, &n)? { + out.push(interp, n)?; + } + Ok(()) + } + + /// Adds `x` and the nodes under it, in document order. + fn subtree(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + self.add(interp, out, axis, test, XNode::Node(x))?; + self.under(interp, out, axis, test, x) + } + + /// Adds the nodes under `x`, in document order. + fn under(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.add(interp, out, axis, test, XNode::Node(c))?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn descendants(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + match n { + XNode::Root(t) => self.subtree(interp, out, axis, test, *t), + XNode::Node(x) => self.under(interp, out, axis, test, *x), + XNode::Ns { .. } => Ok(()), + } + } + + fn children(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let x = match n { + XNode::Root(t) => return self.add(interp, out, axis, test, XNode::Node(*t)), + XNode::Node(x) => *x, + XNode::Ns { .. } => return Ok(()), + }; + let count = interp.xml_node(x)?.children.len(); + for i in 0..count { + if let Some(c) = interp.xml_node(x)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + fn ancestors(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let mut cur = parent_of(interp, n)?; + while let Some(p) = cur { + cur = parent_of(interp, &p)?; + self.add(interp, out, axis, test, p)?; + } + Ok(()) + } + + fn siblings(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + let Some((p, at)) = child_position(interp, *x)? else { return Ok(()) }; + let count = interp.xml_node(p)?.children.len(); + let range: Box<dyn Iterator<Item = usize>> = if axis == Axis::FollowingSibling { Box::new(at + 1..count) } else { Box::new((0..at).rev()) }; + for i in range { + if let Some(c) = interp.xml_node(p)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + /// The node the following and preceding axes start from: an attribute's or a namespace + /// node's element, or the node itself. The flag is true for the first two. + fn start_of(interp: &Interp, n: &XNode) -> JsResult<Option<(ObjId, bool)>> { + Ok(match n { + XNode::Root(_) => None, + XNode::Ns { owner, .. } => Some((*owner, true)), + XNode::Node(x) => match interp.xml_node(*x)?.parent { + Some(p) if interp.xml_kind(*x)? == XmlKind::Attribute => Some((p, true)), + _ => Some((*x, false)), + }, + }) + } + + fn following(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, owned)) = Self::start_of(interp, n)? else { return Ok(()) }; + if owned { + self.under(interp, out, axis, test, cur)?; + } + while let Some((p, at)) = child_position(interp, cur)? { + let count = interp.xml_node(p)?.children.len(); + for i in at + 1..count { + if let Some(s) = interp.xml_node(p)?.children.get(i).copied() { + self.subtree(interp, out, axis, test, s)?; + } + } + cur = p; + } + Ok(()) + } + + fn preceding(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, _)) = Self::start_of(interp, n)? else { return Ok(()) }; + while let Some((p, at)) = child_position(interp, cur)? { + for i in (0..at).rev() { + let Some(s) = interp.xml_node(p)?.children.get(i).copied() else { continue }; + let mut part = Nodes::new(interp); + self.subtree(interp, &mut part, axis, test, s)?; + for x in part.list.into_iter().rev() { + out.push(interp, x)?; + } + } + cur = p; + } + Ok(()) + } + + fn attributes(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let count = interp.xml_node(*x)?.attrs.len(); + for i in 0..count { + if let Some(a) = interp.xml_node(*x)?.attrs.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(a))?; + } + } + Ok(()) + } + + fn namespaces(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let xml = key("xml"); + let mut at = 0u32; + let mut has_xml = false; + for ns in scope_of(interp, Some(*x))? { + let Some(prefix) = ns.prefix else { continue }; + if prefix.is_empty() && ns.uri.is_empty() { + continue; + } + has_xml |= prefix == xml; + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri })?; + at += 1; + } + if !has_xml { + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix: xml, uri: key(XML_URI) })?; + } + Ok(()) + } + + /// The nodes `axis` reaches from `n` that pass `test`, nearest first. + fn axis(&self, interp: &mut Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult<Nodes> { + let mut out = Nodes::new(interp); + match axis { + Axis::Itself => self.add(interp, &mut out, axis, test, n.clone())?, + Axis::Child => self.children(interp, &mut out, axis, test, n)?, + Axis::Descendant => self.descendants(interp, &mut out, axis, test, n)?, + Axis::DescendantOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.descendants(interp, &mut out, axis, test, n)?; + } + Axis::Parent => { + if let Some(p) = parent_of(interp, n)? { + self.add(interp, &mut out, axis, test, p)?; + } + } + Axis::Ancestor => self.ancestors(interp, &mut out, axis, test, n)?, + Axis::AncestorOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.ancestors(interp, &mut out, axis, test, n)?; + } + Axis::FollowingSibling | Axis::PrecedingSibling => self.siblings(interp, &mut out, axis, test, n)?, + Axis::Following => self.following(interp, &mut out, axis, test, n)?, + Axis::Preceding => self.preceding(interp, &mut out, axis, test, n)?, + Axis::Attribute => self.attributes(interp, &mut out, axis, test, n)?, + Axis::Namespace => self.namespaces(interp, &mut out, axis, test, n)?, + } + Ok(out) + } + + // ---- string values and conversions ---- + + /// The string-value of a node (XPath 1.0 section 5): the text under an element or the root, + /// the value of any other node, and a namespace node's URI. + fn string_value(&mut self, interp: &mut Interp, n: &XNode) -> JsResult<Text> { + let x = match n { + XNode::Ns { uri, .. } => return Ok(Text::plain(interp, uri.clone())), + XNode::Root(t) => *t, + XNode::Node(x) => *x, + }; + let kind = interp.xml_kind(x)?; + if kind == XmlKind::Element { + return self.text_under(interp, x); + } + if matches!(n, XNode::Root(_)) && kind != XmlKind::Text { + return Ok(Text::plain(interp, empty())); + } + let value = interp.xml_node(x)?.value.clone(); + let units = flat_charged(interp, &value)?; + charge_units(interp, units.len())?; + Ok(Text::plain(interp, units)) + } + + fn text_under(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<Text> { + let mut buf = TextBuf::new(interp); + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + match interp.xml_kind(c)? { + XmlKind::Text => { + let value = interp.xml_node(c)?.value.clone(); + let units = flat_charged(interp, &value)?; + buf.push(interp, &units)?; + } + XmlKind::Element => push_held(interp, &mut held, &mut stack, (c, 0))?, + _ => {} + } + } + Ok(buf.finish()) + } + + fn text_of(&mut self, interp: &mut Interp, v: Val) -> JsResult<Text> { + match v { + Val::Set(s) => match s.list.first() { + Some(n) => self.string_value(interp, n), + None => Ok(Text::plain(interp, empty())), + }, + Val::Bool(b) => Ok(Text::plain(interp, key(if b { "true" } else { "false" }))), + Val::Num(n) => Ok(Text::plain(interp, key(&num_text(n)))), + Val::Str(t) => { + // The caller reads the units, so they take fuel each time a value is used. + charge_units(interp, t.units.len())?; + Ok(t) + } + } + } + + /// The number a node's string-value stands for. + fn node_number(&mut self, interp: &mut Interp, n: &XNode) -> JsResult<f64> { + let t = self.string_value(interp, n)?; + str_num(interp, &t.units) + } + + fn number_of(&mut self, interp: &mut Interp, v: Val) -> JsResult<f64> { + match v { + Val::Num(n) => Ok(n), + Val::Bool(b) => Ok(f64::from(u8::from(b))), + other => { + let t = self.text_of(interp, other)?; + str_num(interp, &t.units) + } + } + } + + // ---- comparisons and operators ---- + + fn compare(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult<bool> { + match (a, b) { + (Val::Set(x), Val::Set(y)) => self.compare_sets(interp, op, &x, &y), + (Val::Set(x), other) => self.compare_set(interp, op, &x, other), + (other, Val::Set(y)) => self.compare_set(interp, flip(op), &y, other), + (x, y) => self.compare_values(interp, op, x, y), + } + } + + fn compare_values(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult<bool> { + if !matches!(op, Bin::Eq | Bin::Ne) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } + let equal = if matches!(a, Val::Bool(_)) || matches!(b, Val::Bool(_)) { + to_bool(&a) == to_bool(&b) + } else if matches!(a, Val::Num(_)) || matches!(b, Val::Num(_)) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } else { + self.text_of(interp, a)?.units == self.text_of(interp, b)?.units + }; + Ok(equal == (op == Bin::Eq)) + } + + /// Compares each node of `set` with `other`, which is not a node-set; `set` is on the left. + fn compare_set(&mut self, interp: &mut Interp, op: Bin, set: &Nodes, other: Val) -> JsResult<bool> { + if let Val::Str(t) = &other { + charge_units(interp, t.units.len())?; + } + let n = match other { + Val::Bool(b) => return self.compare_values(interp, op, Val::Bool(!set.list.is_empty()), Val::Bool(b)), + Val::Str(t) if matches!(op, Bin::Eq | Bin::Ne) => { + for node in &set.list { + if (self.string_value(interp, node)?.units == t.units) == (op == Bin::Eq) { + return Ok(true); + } + } + return Ok(false); + } + Val::Str(t) => str_num(interp, &t.units)?, + Val::Num(n) => n, + Val::Set(_) => return Err(internal()), + }; + for node in &set.list { + let v = self.node_number(interp, node)?; + if compare_numbers(op, v, n) { + return Ok(true); + } + } + Ok(false) + } + + fn strings_of(&mut self, interp: &mut Interp, held: &mut HeldBytes, set: &Nodes) -> JsResult<HashSet<Key>> { + let mut out = HashSet::new(); + for node in &set.list { + let t = self.string_value(interp, node)?; + held.add(interp, ENTRY_BYTES.saturating_add(t.units.len().saturating_mul(2)))?; + out.insert(t.units); + } + Ok(out) + } + + /// The smallest and largest numbers the nodes' string-values stand for, leaving out NaN. + fn number_range(&mut self, interp: &mut Interp, set: &Nodes) -> JsResult<Option<(f64, f64)>> { + let mut range: Option<(f64, f64)> = None; + for node in &set.list { + let v = self.node_number(interp, node)?; + if !v.is_nan() { + range = Some(range.map_or((v, v), |(lo, hi)| (lo.min(v), hi.max(v)))); + } + } + Ok(range) + } + + fn compare_sets(&mut self, interp: &mut Interp, op: Bin, a: &Nodes, b: &Nodes) -> JsResult<bool> { + if a.list.is_empty() || b.list.is_empty() { + return Ok(false); + } + if matches!(op, Bin::Eq | Bin::Ne) { + let mut held = HeldBytes::new(interp); + let right = self.strings_of(interp, &mut held, b)?; + for node in &a.list { + let s = self.string_value(interp, node)?; + let found = if op == Bin::Eq { right.contains(&s.units) } else { right.len() > 1 || !right.contains(&s.units) }; + if found { + return Ok(true); + } + } + return Ok(false); + } + let (Some((a_lo, a_hi)), Some((b_lo, b_hi))) = (self.number_range(interp, a)?, self.number_range(interp, b)?) else { return Ok(false) }; + Ok(match op { + Bin::Lt | Bin::Le => compare_numbers(op, a_lo, b_hi), + _ => compare_numbers(op, a_hi, b_lo), + }) + } + + fn binary(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult<Val> { + match op { + Bin::Union => { + let (Val::Set(mut x), Val::Set(y)) = (a, b) else { + return Err(interp.type_error("both sides of an XPath | must select nodes")); + }; + if x.list.is_empty() { + return Ok(Val::Set(y)); + } + for n in y.list { + x.push(interp, n)?; + } + Ok(Val::Set(self.sort(interp, x)?)) + } + Bin::Add | Bin::Sub | Bin::Mul | Bin::Div | Bin::Mod => { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + Ok(Val::Num(match op { + Bin::Add => x + y, + Bin::Sub => x - y, + Bin::Mul => x * y, + Bin::Div => x / y, + _ => x % y, + })) + } + Bin::Or | Bin::And => Err(internal()), + _ => Ok(Val::Bool(self.compare(interp, op, a, b)?)), + } + } + + // ---- functions ---- + + fn set_arg(interp: &mut Interp, v: Option<Val>, name: &str) -> JsResult<Nodes> { + match v { + Some(Val::Set(s)) => Ok(s), + _ => Err(interp.type_error(&format!("the XPath function {name} needs a node-set"))), + } + } + + fn text_arg(&mut self, interp: &mut Interp, v: Option<Val>, ctx: &Ctx) -> JsResult<Text> { + match v { + Some(v) => self.text_of(interp, v), + None => self.string_value(interp, &ctx.node), + } + } + + fn number_arg(&mut self, interp: &mut Interp, v: Option<Val>) -> JsResult<f64> { + let v = v.ok_or_else(internal)?; + self.number_of(interp, v) + } + + fn call(&mut self, interp: &mut Interp, f: Func, args: Vec<Val>, ctx: &Ctx) -> JsResult<Val> { + let mut args = args.into_iter(); + Ok(match f { + Func::Last => Val::Num(ctx.size as f64), + Func::Position => Val::Num(ctx.pos as f64), + Func::Count => Val::Num(Self::set_arg(interp, args.next(), "count")?.list.len() as f64), + Func::Id => Val::Set(Nodes::new(interp)), + Func::LocalName | Func::NamespaceUri | Func::Name => Val::Str(self.name_function(interp, f, args.next(), ctx)?), + Func::Sum => { + let set = Self::set_arg(interp, args.next(), "sum")?; + let mut total = 0.0; + for n in &set.list { + total += self.node_number(interp, n)?; + } + Val::Num(total) + } + Func::Boolean => Val::Bool(to_bool(&args.next().ok_or_else(internal)?)), + Func::Not => Val::Bool(!to_bool(&args.next().ok_or_else(internal)?)), + Func::True => Val::Bool(true), + Func::False => Val::Bool(false), + Func::Lang => { + let wanted = self.text_arg(interp, args.next(), ctx)?; + Val::Bool(self.lang(interp, &ctx.node, &wanted.units)?) + } + Func::Number => match args.next() { + Some(v) => Val::Num(self.number_of(interp, v)?), + None => Val::Num(self.node_number(interp, &ctx.node)?), + }, + Func::Floor => Val::Num(self.number_arg(interp, args.next())?.floor()), + Func::Ceiling => Val::Num(self.number_arg(interp, args.next())?.ceil()), + Func::Round => Val::Num(round(self.number_arg(interp, args.next())?)), + _ => return self.string_function(interp, f, args.collect(), ctx), + }) + } + + fn string_function(&mut self, interp: &mut Interp, f: Func, args: Vec<Val>, ctx: &Ctx) -> JsResult<Val> { + let mut texts = Vec::new(); + let mut held = HeldBytes::new(interp); + for v in args { + let t = self.text_of(interp, v)?; + push_held(interp, &mut held, &mut texts, t)?; + } + if texts.is_empty() && matches!(f, Func::String | Func::StringLength | Func::NormalizeSpace) { + texts.push(self.string_value(interp, &ctx.node)?); + } + if f == Func::String { + return Ok(Val::Str(texts.into_iter().next().ok_or_else(internal)?)); + } + let units = |at: usize| texts.get(at).map(|t| &*t.units).unwrap_or_default(); + Ok(match f { + Func::StringLength => Val::Num(char::decode_utf16(units(0).iter().copied()).count() as f64), + Func::StartsWith => Val::Bool(units(0).starts_with(units(1))), + Func::Contains => Val::Bool(find(interp, units(0), units(1))?.is_some()), + Func::SubstringBefore => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(..i)).unwrap_or_default())?) + } + Func::SubstringAfter => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(i + units(1).len()..)).unwrap_or_default())?) + } + Func::Concat => { + let mut buf = TextBuf::new(interp); + for t in &texts { + buf.push(interp, &t.units)?; + } + Val::Str(buf.finish()) + } + Func::NormalizeSpace => Val::Str(normalize_space(interp, units(0))?), + Func::Translate => Val::Str(translate(interp, units(0), units(1), units(2))?), + _ => return Err(internal()), + }) + } + + fn slice(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<Text> { + let mut buf = TextBuf::new(interp); + buf.push(interp, units)?; + Ok(buf.finish()) + } + + fn substring(&mut self, interp: &mut Interp, args: Vec<Val>) -> JsResult<Val> { + let mut args = args.into_iter(); + let text = self.text_of(interp, args.next().ok_or_else(internal)?)?; + let start = round(self.number_arg(interp, args.next())?); + let end = match args.next() { + Some(len) => start + round(self.number_of(interp, len)?), + None => f64::INFINITY, + }; + charge_units(interp, text.units.len())?; + let mut held = HeldBytes::new(interp); + // The code points take 4 bytes each, and the result up to twice its 2-byte units. + held.add(interp, text.units.len().saturating_mul(8))?; + let mut out = Vec::new(); + for (i, p) in code_points(&text.units).into_iter().enumerate() { + let position = (i + 1) as f64; + if position >= start && position < end { + push_point(&mut out, p); + } + } + Ok(Val::Str(Text { units: Rc::from(out), _held: held })) + } + + fn name_function(&mut self, interp: &mut Interp, f: Func, arg: Option<Val>, ctx: &Ctx) -> JsResult<Text> { + let node = match arg { + None => Some(ctx.node.clone()), + Some(Val::Set(s)) => s.list.into_iter().next(), + Some(_) => return Err(interp.type_error("the XPath name functions need a node-set")), + }; + let units = match node { + None | Some(XNode::Root(_)) => empty(), + Some(XNode::Ns { prefix, .. }) => { + if f == Func::NamespaceUri { + empty() + } else { + prefix + } + } + Some(XNode::Node(x)) => node_name(interp, f, x)?, + }; + Ok(Text::plain(interp, units)) + } + + /// Whether the context node's language, from the nearest `xml:lang` attribute on it or an + /// ancestor, is `wanted` or a sublanguage of it, ignoring case. + fn lang(&mut self, interp: &mut Interp, n: &XNode, wanted: &[u16]) -> JsResult<bool> { + let mut cur = match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(*x), + XNode::Ns { owner, .. } => Some(*owner), + }; + let xml = key(XML_URI); + while let Some(x) = cur { + interp.use_fuel()?; + let node = interp.xml_node(x)?; + let parent = node.parent; + let count = node.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() else { break }; + let attr = interp.xml_node(a)?; + if attr.name.as_ref().is_some_and(|q| name_uri(q) == &*xml && units_are(&q.local, "lang")) { + let value = attr.value.clone(); + let value = flat_charged(interp, &value)?; + let head = value.get(..wanted.len()).unwrap_or_default(); + return Ok(same_ignoring_case(head, wanted) && (value.len() == wanted.len() || value.get(wanted.len()) == Some(&DASH))); + } + } + cur = parent; + } + Ok(false) + } + + // ---- running ---- + + fn task(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.tasks, t) + } + + fn val(&mut self, interp: &mut Interp, v: Val) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.vals, v) + } + + fn pop(&mut self) -> JsResult<Val> { + self.vals.pop().ok_or_else(internal) + } + + fn root_of(interp: &mut Interp, n: &XNode) -> JsResult<XNode> { + Ok(match n { + XNode::Root(t) => XNode::Root(*t), + XNode::Node(x) => XNode::Root(top_of(interp, *x)?), + XNode::Ns { owner, .. } => XNode::Root(top_of(interp, *owner)?), + }) + } + + fn bound_value(&mut self, interp: &mut Interp, at: usize) -> JsResult<Val> { + Ok(match self.bound.get(at).ok_or_else(internal)? { + Bound::Bool(b) => Val::Bool(*b), + Bound::Num(n) => Val::Num(*n), + Bound::Str(s) => Val::Str(Text::plain(interp, s.clone())), + Bound::Set(list) => { + let list = list.clone(); + let mut nodes = Nodes::new(interp); + nodes.held.add(interp, list.len().saturating_mul(size_of::<XNode>()))?; + nodes.list = list; + Val::Set(nodes) + } + }) + } + + fn eval(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult<()> { + let parsed = self.parsed; + match parsed.exprs.get(e).ok_or_else(internal)? { + Expr::Num(n) => self.val(interp, Val::Num(*n)), + Expr::Lit(s) => { + let t = Text::plain(interp, s.clone()); + self.val(interp, Val::Str(t)) + } + Expr::Var(at) => { + let v = self.bound_value(interp, *at)?; + self.val(interp, v) + } + Expr::Neg(a) => { + self.task(interp, Task::Neg)?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op @ (Bin::And | Bin::Or), a, b) => { + self.task(interp, Task::Logic { and: *op == Bin::And, rhs: *b, ctx: ctx.clone() })?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op, a, b) => { + self.task(interp, Task::Bin(*op))?; + self.task(interp, Task::Eval(*b, ctx.clone()))?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Call(f, args) => { + self.task(interp, Task::Call(*f, args.len(), ctx.clone()))?; + for a in args.iter().rev() { + self.task(interp, Task::Eval(*a, ctx.clone()))?; + } + Ok(()) + } + Expr::Path { filter: Some((primary, _)), .. } => { + self.task(interp, Task::PathStart(e))?; + self.task(interp, Task::Eval(*primary, ctx)) + } + Expr::Path { filter: None, absolute, steps } => { + let start = if *absolute { Self::root_of(interp, &ctx.node)? } else { ctx.node }; + let mut inputs = Nodes::new(interp); + inputs.push(interp, start)?; + if steps.is_empty() { + return self.val(interp, Val::Set(inputs)); + } + let first = self.step_axis(interp, e, 1, &inputs.list.first().cloned().ok_or_else(internal)?)?; + let run = self.new_run(interp, e, 1, inputs, first); + self.run_path(interp, Box::new(run)) + } + } + } + + fn new_run(&self, interp: &Interp, path: usize, stage: usize, inputs: Nodes, cur: Nodes) -> PathRun { + PathRun { + path, + stage, + inputs, + ci: 0, + cur, + pred: 0, + at: 0, + kept: Nodes::new(interp), + waiting: false, + out: Nodes::new(interp), + seen: HashSet::new(), + seen_held: HeldBytes::new(interp), + } + } + + fn step(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + match t { + Task::Eval(e, ctx) => self.eval(interp, e, ctx), + Task::Bin(op) => { + let b = self.pop()?; + let a = self.pop()?; + let v = self.binary(interp, op, a, b)?; + self.val(interp, v) + } + Task::Neg => { + let a = self.pop()?; + let n = self.number_of(interp, a)?; + self.val(interp, Val::Num(-n)) + } + Task::Logic { and, rhs, ctx } => { + let left = to_bool(&self.pop()?); + if left != and { + return self.val(interp, Val::Bool(left)); + } + self.task(interp, Task::Bool)?; + self.task(interp, Task::Eval(rhs, ctx)) + } + Task::Bool => { + let v = to_bool(&self.pop()?); + self.val(interp, Val::Bool(v)) + } + Task::Call(f, n, ctx) => { + let args = self.vals.split_off(self.vals.len().checked_sub(n).ok_or_else(internal)?); + let v = if f == Func::Substring { self.substring(interp, args)? } else { self.call(interp, f, args, &ctx)? }; + self.val(interp, v) + } + Task::PathStart(e) => { + let Val::Set(set) = self.pop()? else { + return Err(interp.type_error("an XPath predicate or step follows a value that is not a node-set")); + }; + let run = self.new_run(interp, e, 0, Nodes::new(interp), set); + self.run_path(interp, Box::new(run)) + } + Task::Path(run) => self.run_path(interp, run), + } + } + + /// The predicates of a path's stage, and its step for a stage after the filter's. + fn stage_of(&self, path: usize, stage: usize) -> JsResult<(&'p [usize], Option<&'p Step>)> { + let parsed = self.parsed; + let Some(Expr::Path { filter, steps, .. }) = parsed.exprs.get(path) else { return Err(internal()) }; + match stage.checked_sub(1) { + None => Ok((filter.as_ref().map(|(_, p)| p.as_slice()).unwrap_or_default(), None)), + Some(s) => { + let step = steps.get(s).ok_or_else(internal)?; + Ok((&step.preds, Some(step))) + } + } + } + + fn step_axis(&self, interp: &mut Interp, path: usize, stage: usize, n: &XNode) -> JsResult<Nodes> { + let (_, step) = self.stage_of(path, stage)?; + let step = step.ok_or_else(internal)?; + self.axis(interp, step.axis, &step.test, n) + } + + /// Runs a path until a predicate needs evaluating, which it schedules before itself, or until + /// the path's node-set is complete, which it pushes. + fn run_path(&mut self, interp: &mut Interp, mut r: Box<PathRun>) -> JsResult<()> { + loop { + interp.use_fuel()?; + if r.waiting { + r.waiting = false; + let v = self.pop()?; + let keep = match v { + Val::Num(n) => n == (r.at + 1) as f64, + other => to_bool(&other), + }; + if keep && let Some(n) = r.cur.list.get(r.at).cloned() { + r.kept.push(interp, n)?; + } + r.at += 1; + } + let (preds, _) = self.stage_of(r.path, r.stage)?; + if let Some(&pred) = preds.get(r.pred) { + if let Some(node) = r.cur.list.get(r.at).cloned() { + let ctx = Ctx { node, pos: r.at + 1, size: r.cur.list.len() }; + r.waiting = true; + self.task(interp, Task::Path(r))?; + return self.task(interp, Task::Eval(pred, ctx)); + } + r.cur = replace(&mut r.kept, Nodes::new(interp)); + r.pred += 1; + r.at = 0; + continue; + } + if !self.next_context(interp, &mut r)? { + return Ok(()); + } + } + } + + /// Adds the current context's nodes to the stage's result and moves to the next context or + /// stage. Returns false when the path is complete and its node-set has been pushed. + fn next_context(&mut self, interp: &mut Interp, r: &mut PathRun) -> JsResult<bool> { + let cur = replace(&mut r.cur, Nodes::new(interp)); + if r.inputs.list.len() > 1 { + for n in cur.list { + if !r.seen.contains(&n) { + r.seen_held.add(interp, ENTRY_BYTES)?; + r.seen.insert(n.clone()); + r.out.push(interp, n)?; + } + } + } else { + r.out = cur; + } + r.ci += 1; + if let Some(n) = r.inputs.list.get(r.ci).cloned() { + r.cur = self.step_axis(interp, r.path, r.stage, &n)?; + r.pred = 0; + r.at = 0; + return Ok(true); + } + let out = replace(&mut r.out, Nodes::new(interp)); + let (_, step) = self.stage_of(r.path, r.stage)?; + let done = if r.inputs.list.len() > 1 { + self.sort(interp, out)? + } else if step.is_some_and(|s| s.axis.reverse()) { + let mut out = out; + out.list.reverse(); + out + } else { + out + }; + r.seen.clear(); + r.seen_held = HeldBytes::new(interp); + r.stage += 1; + let more = self.stage_of(r.path, r.stage).is_ok(); + let Some(first) = done.list.first().cloned().filter(|_| more) else { + self.val(interp, Val::Set(done))?; + return Ok(false); + }; + r.inputs = done; + r.ci = 0; + r.cur = self.step_axis(interp, r.path, r.stage, &first)?; + r.pred = 0; + r.at = 0; + Ok(true) + } + + fn run(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult<Val> { + self.tasks.clear(); + self.vals.clear(); + self.task(interp, Task::Eval(e, ctx))?; + while let Some(t) = self.tasks.pop() { + interp.use_fuel()?; + self.step(interp, t)?; + } + let v = self.pop()?; + if !self.vals.is_empty() { + return Err(internal()); + } + Ok(v) + } + + /// Binds the expression's prefixes to the namespaces in scope on `x`. + fn resolve_prefixes(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.uris.clear(); + if self.parsed.prefixes.is_empty() { + return Ok(()); + } + let scope = scope_of(interp, Some(x))?; + let parsed = self.parsed; + for p in &parsed.prefixes { + let uri = if units_are(p, "xml") { + key(XML_URI) + } else { + match scope.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { + Some(ns) if !ns.uri.is_empty() => ns.uri.clone(), + _ => { + let shown = String::from_utf16_lossy(p); + return Err(interp.type_error(&format!("the XPath prefix {shown} is not bound to a namespace"))); + } + } + }; + self.held.add(interp, ENTRY_BYTES)?; + self.uris.insert(p.clone(), uri); + } + Ok(()) + } + + /// The nodes the expression selects with `x` as the context node. + fn select(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<Vec<ObjId>> { + self.resolve_prefixes(interp, x)?; + let root = self.parsed.root; + let Val::Set(set) = self.run(interp, root, Ctx { node: XNode::Node(x), pos: 1, size: 1 })? else { + return Err(interp.type_error("the XPath expression does not evaluate to a node-set")); + }; + let mut out = Vec::new(); + let mut held = HeldBytes::new(interp); + for n in set.list { + match n { + XNode::Node(id) => push_held(interp, &mut held, &mut out, id)?, + XNode::Root(_) => return Err(interp.type_error("the XPath expression selects the root node, which is not an XML value")), + XNode::Ns { .. } => return Err(interp.type_error("the XPath expression selects a namespace node, which is not an XML value")), + } + } + Ok(out) + } +} + +fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult<Key> { + let node = interp.xml_node(x)?; + let (kind, parent) = (node.kind, node.parent); + let Some(name) = node.name.clone() else { return Ok(empty()) }; + match (f, kind) { + (_, XmlKind::Text | XmlKind::Comment) => Ok(empty()), + (Func::NamespaceUri, XmlKind::Pi) => Ok(empty()), + (Func::NamespaceUri, _) => Ok(Rc::from(name_uri(&name))), + (Func::LocalName, _) | (_, XmlKind::Pi) => Ok(name.local), + _ => qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), + } +} + +/// An element's or attribute's name as a QName, with a prefix declared for its namespace. +fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option<ObjId>, name: &XmlName, attribute: bool) -> JsResult<Key> { + let uri = name_uri(name); + if uri.is_empty() { + return Ok(name.local.clone()); + } + let prefix = if units_are(uri, XML_URI) { + key("xml") + } else { + match &name.prefix { + Some(p) if !(attribute && p.is_empty()) => p.clone(), + _ => { + let scope = scope_of(interp, if attribute { parent } else { Some(x) })?; + let usable = |ns: &XmlNs| *ns.uri == *uri && ns.prefix.as_ref().is_some_and(|p| !(attribute && p.is_empty())); + scope.into_iter().find(usable).and_then(|ns| ns.prefix).unwrap_or_else(empty) + } + } + }; + if prefix.is_empty() { + return Ok(name.local.clone()); + } + let mut units = prefix.to_vec(); + units.push(COLON); + units.extend_from_slice(&name.local); + Ok(Rc::from(units)) +} + +/// The first index where `needle` occurs in `hay`. Every position tried takes fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16]) -> JsResult<Option<usize>> { + string_builtins::find(interp, hay, needle, 0) +} + +fn normalize_space(interp: &mut Interp, units: &[u16]) -> JsResult<Text> { + let mut buf = TextBuf::new(interp); + for word in units.split(|&c| is_xml_space(c)).filter(|w| !w.is_empty()) { + if !buf.units.is_empty() { + buf.push(interp, &[0x20])?; + } + buf.push(interp, word)?; + } + Ok(buf.finish()) +} + +fn translate(interp: &mut Interp, units: &[u16], from: &[u16], to: &[u16]) -> JsResult<Text> { + charge_units(interp, units.len() + from.len() + to.len())?; + let mut held = HeldBytes::new(interp); + held.add(interp, from.len().saturating_add(units.len()).saturating_add(to.len()).saturating_mul(ENTRY_BYTES))?; + let to = code_points(to); + let mut map: HashMap<u32, Option<u32>> = HashMap::new(); + for (i, p) in code_points(from).into_iter().enumerate() { + map.entry(p).or_insert_with(|| to.get(i).copied()); + } + let mut out = Vec::new(); + for p in code_points(units) { + match map.get(&p) { + Some(Some(t)) => push_point(&mut out, *t), + Some(None) => {} + None => push_point(&mut out, p), + } + } + Ok(Text { units: Rc::from(out), _held: held }) +} + +/// Reads the variables the expression names from `variables`. +fn bind(interp: &mut Interp, held: &mut HeldBytes, parsed: &Parsed, variables: &Value) -> JsResult<Vec<Bound>> { + let mut out = Vec::new(); + for name in &parsed.vars { + let v = match variables { + Value::Object(o) if interp.has_property(*o, name)? => interp.get_from(*o, name, variables)?, + _ => Value::Undefined, + }; + if matches!(v, Value::Undefined) { + let shown = String::from_utf16_lossy(name); + return Err(interp.type_error(&format!("the XPath variable ${shown} is not bound"))); + } + interp.temp_roots.push(v.clone()); + let b = match (interp.classify(&v), &v) { + (Xv::Node(n), _) => Bound::Set(vec![XNode::Node(n)]), + (Xv::List(l), _) => { + // A later getter may change the list, so its items are held as roots themselves. + let count = interp.xml_list(l)?.items.len(); + held.add(interp, count.saturating_mul(size_of::<ObjId>() + size_of::<XNode>() + size_of::<Value>()))?; + let items = interp.xml_list(l)?.items.clone(); + interp.temp_roots.extend(items.iter().map(|&n| Value::Object(n))); + Bound::Set(items.into_iter().map(XNode::Node).collect()) + } + (Xv::Other, Value::Bool(b)) => Bound::Bool(*b), + (Xv::Other, Value::Number(n)) => Bound::Num(*n), + (Xv::Other, _) => { + let s = interp.to_str(&v)?; + Bound::Str(flat_charged(interp, &s)?) + } + }; + push_held(interp, held, &mut out, b)?; + } + Ok(out) +} + +/// The nodes an expression runs on: the XML value itself, or each element of an XMLList. +fn contexts(interp: &mut Interp, held: &mut HeldBytes, this: &Value) -> JsResult<Vec<ObjId>> { + match interp.classify(this) { + Xv::Node(n) => Ok(vec![n]), + Xv::List(l) => { + let count = interp.xml_list(l)?.items.len(); + let mut out = Vec::new(); + for i in 0..count { + interp.use_fuel()?; + let Some(item) = interp.xml_list(l)?.items.get(i).copied() else { break }; + if interp.xml_kind(item)? == XmlKind::Element { + push_held(interp, held, &mut out, item)?; + } + } + Ok(out) + } + Xv::Other => Err(interp.type_error("the method needs an XML value")), + } +} + +/// `xpath(expression[, variables])`: the nodes the XPath 1.0 `expression` selects with this XML +/// value as the context node, or, for an XMLList, with each of its elements in turn, as one +/// XMLList. Each property of `variables` binds the variable of its name. +pub(crate) fn xpath(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + if matches!(interp.classify(&this), Xv::Other) { + return Err(interp.type_error("the method needs an XML value")); + } + let Value::String(source) = arg(args, 0) else { + return Err(interp.type_error("xpath needs the expression as a string")); + }; + let variables = arg(args, 1); + if !matches!(variables, Value::Undefined | Value::Object(_)) { + return Err(interp.type_error("xpath needs its variables as an object")); + } + interp.rooted(&[this.clone(), variables.clone()], |i| { + let units = flat_charged(i, &source)?; + let parsed = parse(i, &units)?; + let mut held = HeldBytes::new(i); + let bound = bind(i, &mut held, &parsed, &variables)?; + let items = contexts(i, &mut held, &this)?; + let out = i.new_xml_list(None, None)?; + i.xml_keep(out); + let mut ev = Ev::new(i, &parsed, bound)?; + for item in items { + for id in ev.select(i, item)? { + i.list_push(out, id)?; + } + } + Ok(Value::Object(out)) + }) +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..f5e85d2c8b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -765,7 +765,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `<!DOCTYPE>` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings. `xml.xpath(expression[, variables])` evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with all 13 axes and the 27 core functions, and returns the selected nodes as an XMLList in document order without repeats. Each property of `variables` binds the variable of its name, an XMLList runs the expression on each of its elements in turn, and a prefix in a name test resolves through the context element's in-scope namespaces. An expression that breaks the grammar gives a `SyntaxError`; an unknown function, a wrong argument count, an unbound prefix or variable, and a result that is not a node-set or that holds the root or a namespace node give a `TypeError`. `id()` selects nothing, and evaluation uses no native stack, takes fuel and counts against the heap limit. `<!DOCTYPE>` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From e83c36785ed4286ad5d9421919c034e269ac5edb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:04:01 -0800 Subject: [PATCH 055/126] Commands: pointer events refuse pen values and spaces of the wrong type ui.pointer and the headless pointer call read a pointer event's pressure, holdMs, tiltX, tiltY and rotation with a default for any value that was not a number, and treated any space other than screen as document space. Each event is now checked before the packet runs: those values must be numbers and space must be doc or screen, with null keeping the default. docs/mcp.md says so. --- crates/mcp/src/headless.rs | 1 + crates/mcp/src/tests.rs | 5 +++++ crates/tools/src/input.rs | 34 +++++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/control/tests.rs | 10 +++++++-- docs/mcp.md | 5 +++-- 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index cf150623ef..69c7b5ba0e 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -239,6 +239,7 @@ impl Headless { let mods = packet_mods(e)?.unwrap_or(base); let kind = s(e, "kind").unwrap_or(""); let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + PointerEvent::json_check(e)?; if s(e, "space") == Some("screen") { return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 86cc6228a0..9a64c688b9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1474,6 +1474,11 @@ fn headless_methods_refuse_params_of_the_wrong_type() { // A whole rectangle comes before the malformed event, and none of it is drawn. ("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"), ("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "pressure": "bad"})), "pressure"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "holdMs": true})), "holdMs"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "tiltX": "5"})), "tiltX"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "rotation": [0]})), "rotation"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "space": "screeen"})), "space"), ] { let e = h.call(method, params.clone()).unwrap_err(); assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index ece5231bd2..9b53d2404c 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -109,6 +109,19 @@ impl PointerEvent { pub fn json_backquote(v: &Value, default: bool) -> Result<bool, String> { v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned())) } + /// Checks the optional keys of a JSON pointer event (control channel, MCP): `pressure`, + /// `holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers, and `space` must be `doc` or + /// `screen`. A key that is absent or null keeps its default. + pub fn json_check(e: &Value) -> Result<(), String> { + let given = |k: &str| e.get(k).filter(|v| !v.is_null()); + if let Some(k) = ["pressure", "holdMs", "tiltX", "tiltY", "rotation"].into_iter().find(|k| given(k).is_some_and(|v| v.as_f64().is_none())) { + return Err(format!("`{k}` must be a number")); + } + if given("space").is_some_and(|v| !matches!(v.as_str(), Some("doc" | "screen"))) { + return Err("`space` must be doc or screen".to_owned()); + } + Ok(()) + } } #[cfg(test)] @@ -135,6 +148,27 @@ mod tests { assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}"); } } + + #[test] + fn json_check_refuses_pen_values_and_spaces_of_the_wrong_type() { + use serde_json::json; + for ok in [ + json!({}), + json!({"pressure": 0.5, "holdMs": 10, "tiltX": -5, "tiltY": 5, "rotation": 400, "space": "screen"}), + json!({"pressure": null, "space": null}), + json!({"space": "doc"}), + ] { + assert_eq!(PointerEvent::json_check(&ok), Ok(()), "{ok}"); + } + for k in ["pressure", "holdMs", "tiltX", "tiltY", "rotation"] { + for bad in [json!("1"), json!(true), json!([1])] { + assert_eq!(PointerEvent::json_check(&json!({k: bad})), Err(format!("`{k}` must be a number")), "{k}: {bad}"); + } + } + for bad in [json!("screeen"), json!(true), json!(1)] { + assert_eq!(PointerEvent::json_check(&json!({"space": bad})), Err("`space` must be doc or screen".to_owned()), "{bad}"); + } + } } /// Keys tools care about. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 34eaa7944e..65489be0dd 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -984,6 +984,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con "doubleclick" | "dblclick" => PointerKind::DoubleClick, other => return Err(format!("unknown pointer kind `{other}`")), }; + PointerEvent::json_check(e)?; let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) }) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 021365d817..1763fd679a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -457,8 +457,8 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { } } -/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any -/// of the packet's events runs. +/// `ui.pointer` refuses an unknown event kind, a pen value or `space` of the wrong type, or a +/// screen-space event with no canvas, before any of the packet's events runs. #[test] fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -471,6 +471,12 @@ fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { for (last, error) in [ (json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"), (json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"), + (json!({"kind": "move", "x": 145, "y": 125, "pressure": "bad"}), "`pressure` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "holdMs": "800"}), "`holdMs` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "tiltY": true}), "`tiltY` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "rotation": "90"}), "`rotation` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screeen"}), "`space` must be doc or screen"), + (json!({"kind": "move", "x": 145, "y": 125, "space": true}), "`space` must be doc or screen"), ] { let events = moves.iter().cloned().chain([last]).collect::<Vec<_>>(); let r = input_request(&mut app, "ui.pointer", json!({"events": events})); diff --git a/docs/mcp.md b/docs/mcp.md index 078488e9d4..bce8a5efc8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1272,8 +1272,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (` control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`, `ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s -`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one -malformed event sends none of its events: +`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer event's `pressure`, +`holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers and its `space` must be `doc` or `screen`. A pointer packet +with one malformed event sends none of its events: ```json {"id": "view.setZoom", "params": {"zoom": "200"}} From cdeb7946e0108e6bec129623636f095b0a7cc311 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:05:30 -0800 Subject: [PATCH 056/126] Data Recovery: a left copy that can't be removed yet stays the session's own drop_left forgot a copy in an area the session left before removing it, so a copy whose removal failed (the area read-only for a moment) could later be offered back to the same session as a crash's copy. The entry now stays until both files are removed, and the next heartbeat tries again. --- crates/engine/src/cmd/recovery.rs | 12 ++++++----- crates/engine/src/tests/recovery.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index bb7fbc6a5c..3424da3ddd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -614,12 +614,14 @@ fn drop_left_done(s: &mut Session) { } } -/// Document `uid`'s copy in an area this session left goes. Best effort: a copy that can't be -/// removed is offered once nobody holds that area, which loses nothing. +/// Document `uid`'s copy in an area this session left goes. A copy that can't be removed now stays +/// this session's, and a later heartbeat removes it. fn drop_left(s: &mut Session, uid: u64) { - if let Some((store, file)) = s.recovery.left.remove(&uid) { - let _ = store.remove(&format!("{file}{DOC_EXT}")); - let _ = store.remove(&format!("{file}{META_EXT}")); + let Some((store, file)) = s.recovery.left.get(&uid) else { return }; + let doc = store.remove(&format!("{file}{DOC_EXT}")); + let meta = store.remove(&format!("{file}{META_EXT}")); + if doc.is_ok() && meta.is_ok() { + s.recovery.left.remove(&uid); } } diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index acd9828141..f0b6a0708c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -903,6 +903,39 @@ fn an_area_whose_lock_file_cannot_be_made_again_is_left_for_a_new_one() { let _ = std::fs::remove_dir_all(&dir); } +/// A copy left behind that can't be removed when its document is saved (its area can't be written +/// to now) stays the session's own: it isn't offered here, and a later heartbeat removes it. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_that_cannot_be_removed_yet_stays_the_sessions_own() { + use std::os::unix::fs::PermissionsExt as _; + + use cmd::recovery::Lock; + // Permissions don't stop the superuser. + if rustix::process::geteuid().is_root() { + return; + } + let base = std::env::temp_dir().join(format!("vc-recovery-leftstuck-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("recovery"); + let path = base.join("doc.vectorcraft").to_string_lossy().to_string(); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap(); + a.execute("document.save", &json!({"path": path})).unwrap(); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap(); + let copy = dir.join(&area).join("Untitled-1-1.vectorcraft"); + assert!(copy.is_file()); + drop(other); + assert!(offered(&mut a).is_empty()); + cmd::recovery::heartbeat(&mut a); + assert!(!copy.exists()); + drop(a); + let _ = std::fs::remove_dir_all(&base); +} + /// A copy left in an area the session had to leave is still the session's own after the app that /// locked that area is gone: the session neither offers nor discards it. #[cfg(unix)] From 0b230f3f17c64f2e980419d37bc26d9654deb299 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:05:53 -0800 Subject: [PATCH 057/126] Tests: cached-frame scale test no longer fails when a parallel test places a file cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes delivered the worker's frame with a 1 ms render time. The canvas key carries the process-wide placed-document generation, so when another test in the same run made a placed bitmap between the delivery and the next frame, the key no longer matched. With a 1 ms last frame, the next frame rendered on the UI thread over the delivered one, and the "the worker's frame arrived" assertion failed. The delivered frame now reports 50 ms, as the frames before it do, so a moved generation queues the next frame on the controlled worker and the delivered frame stays shown. --- crates/ui-egui/src/canvas/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4f962fdb5d..9067bdb297 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2850,10 +2850,13 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() { "{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}" ); } - // Deliver the actual replacement and verify that it replaces the cached key. + // Deliver the actual replacement and verify that it replaces the cached key. The frame + // reports a slow render: when a parallel test makes a placed document's bitmap, the key's + // process-wide `placed` generation moves, and the next frame goes to the controlled worker + // instead of rendering over the delivered frame on this thread. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0, reused: false }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0, reused: false }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } From f1251af6e44f2316cfa0551ae798b7c0e3c846af Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:10:30 -0800 Subject: [PATCH 058/126] DXF: export options leave placed images and fill hatches out, and CNC / CAM turns both off DXF Options gets Include Placed Images and Include Fill Hatches, and document.exportDxf and document.export {format: dxf} take includeImages and includeHatches. Both are VectorCraft's own options and default to true, so Custom exports write what they wrote before. - includeImages: false writes no IMAGE entity, IMAGEDEF, reactor or linked file for a placed image, and does not decode its pixels. - includeHatches: false writes each filled area's outlines without the solid HATCH after them. - Each adds one note to warnings when it left something out. R12, which has neither, keeps its own notes. - The CNC / CAM preset sets both to false, so its drawing holds the cutting paths alone; the options given still override it, and changing either checkbox shows Custom. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/cad/src/lib.rs | 13 ++- crates/cad/src/scene.rs | 18 +++- crates/cad/src/tests.rs | 117 ++++++++++++++++++++++ crates/engine/src/cmd/fileio/dxf.rs | 20 +++- crates/engine/src/cmd/fileio/tests_dxf.rs | 47 +++++++++ crates/ui-egui/src/dialogs/dxf_options.rs | 13 ++- crates/ui-egui/src/dialogs/tests_dxf.rs | 55 ++++++++-- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/control-protocol.md | 5 +- docs/mcp.md | 8 +- docs/target-app-parity.md | 3 +- 37 files changed, 332 insertions(+), 21 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 525e9b6734..8125d02c93 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -281,6 +281,10 @@ pub struct DxfOptions { pub scale_lineweights: bool, pub colors: ColorDepth, pub raster: RasterFormat, + /// Placed images are written as IMAGE entities with their files (else left out, undecoded). + pub include_images: bool, + /// Filled areas get a solid HATCH after their outlines (else the outlines alone). + pub include_hatches: bool, pub preserve: Preserve, /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have @@ -303,6 +307,8 @@ impl Default for DxfOptions { scale_lineweights: false, colors: ColorDepth::default(), raster: RasterFormat::default(), + include_images: true, + include_hatches: true, preserve: Preserve::default(), alter_paths: false, outline_text: false, @@ -322,7 +328,8 @@ pub enum DxfPreset { /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), - /// and placed images as PNG. + /// and placed images and fill hatches left out (fills are their outlines), with PNG as the + /// format images take when they are put back in. Cnc, } @@ -360,6 +367,8 @@ impl DxfPreset { scale_lineweights: false, colors: ColorDepth::Aci256, raster: RasterFormat::Png, + include_images: false, + include_hatches: false, preserve: Preserve::Editability, alter_paths: false, outline_text: true, @@ -377,6 +386,8 @@ impl DxfPreset { && o.scale_lineweights == c.scale_lineweights && o.colors == c.colors && o.raster == c.raster + && o.include_images == c.include_images + && o.include_hatches == c.include_hatches && o.preserve == c.preserve && o.alter_paths == c.alter_paths && o.outline_text == c.outline_text; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 3faf94423f..300407eed6 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,5 +1,6 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint -//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes +//! order. Fills are their outlines then solid hatches (the outlines alone in R12 or with +//! hatches left out), placed images are linked image files unless left out, strokes //! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or //! filled outlines where the look needs them. @@ -28,6 +29,8 @@ const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are wr const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque"; const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines"; const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out"; +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; const WIDE_LINES: &str = "lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width"; const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; @@ -878,8 +881,8 @@ impl<'a> Scene<'a> { Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } - /// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser - /// and cutter software reads the outlines and skips hatches. `clean`: the path has no + /// A filled area as its outlines followed by a solid hatch (R12, or with hatches left out: the + /// outlines alone). Laser and cutter software reads the outlines and skips hatches. `clean`: the path has no /// overlaps, so the hatch's even-odd rule reads it as it is. fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { if !self.o.version.handles() { @@ -893,11 +896,16 @@ impl<'a> Scene<'a> { .flatten() .map(|p| p.to_bezpath()); let outline = normalized.as_ref().unwrap_or(bp); + // A fill with no loop of three points or more encloses no area: no outline and no hatch. let loops: Vec<Vec<Point>> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect(); if loops.is_empty() { return; } self.lines(outline, style, 0.0); + if !self.o.include_hatches { + self.warn(HATCHES_OFF); + return; + } let h = self.handle(); let layer = self.layer.clone(); self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch"); @@ -1285,6 +1293,10 @@ impl<'a> Scene<'a> { self.warn(NO_IMAGES); return; } + if !self.o.include_images { + self.warn(IMAGES_OFF); + return; + } let Some(i) = self.image_def(im) else { return }; let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 90e4cbc2c6..2ffa594f44 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -843,10 +843,127 @@ fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_stro assert!(out.images.is_empty()); assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_images: true, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_hatches: true, ..o.clone() }), DxfPreset::Custom); assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); } +/// A 4 × 2 px PNG placed at (10, 20) under key `raster-1`, and before it in paint order +/// `fill`'s rectangle. +fn image_and_fill_doc(fill: Paint) -> Document { + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = doc_with(|d| { + let im = ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ + path(d, shapes::rectangle(Rect::new(50.0, 10.0, 150.0, 60.0)), fill, Paint::None, 0.0), + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))), + ] + }); + d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); + d +} + +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; + +#[test] +fn fills_without_hatches_keep_their_outlines() { + let d = shapes_doc(); + let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); + assert_eq!(out.warnings, [HATCHES_OFF]); + let e = entities(&out); + assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); + // Hatches stay by default, with no note. + let on = export(&d, &opts(&d)).unwrap(); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + // The outlines are the ones written beside hatches, also for overlapping subpaths, which are + // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. + let compound = doc_with(|d| { + let mut bp = shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)).to_bezpath(); + bp.extend(shapes::rectangle(Rect::new(40.0, 40.0, 90.0, 90.0)).to_bezpath().elements().iter().copied()); + let p = PathData::from_bezpath(&bp); + vec![ + path(d, p, Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::rectangle(Rect::new(10.0, 70.0, 90.0, 70.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::line(Point::new(100.0, 10.0), Point::new(150.0, 60.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + ] + }); + let without_hatches = |o: &DxfOutput| { + let mut e = entities(o); + e.retain(|x| kind(x) != "HATCH"); + e.into_iter().map(|x| x.into_iter().filter(|p| p.0 != 5).collect::<Pairs>()).collect::<Vec<_>>() + }; + let (c_on, c_off) = + (export(&compound, &opts(&compound)).unwrap(), export(&compound, &DxfOptions { include_hatches: false, ..opts(&compound) }).unwrap()); + assert_eq!(kinds(&c_on), ["LWPOLYLINE", "HATCH"], "one merged outline"); + assert_eq!(without_hatches(&c_off), without_hatches(&c_on)); + assert_eq!(without_hatches(&out), without_hatches(&on)); + // Strokes outlined for their look are areas too: their outlines stay. + let altered = + export(&d, &DxfOptions { include_hatches: false, alter_paths: true, preserve: Preserve::Editability, version: DxfVersion::R14, ..opts(&d) }) + .unwrap(); + assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); + // Nothing filled: no note. + let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + // R12 has no hatches anyway: the same outlines, and its own note alone. + let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); + let (off, on) = (r12(false), r12(true)); + assert_eq!(off.bytes, on.bytes); + assert_eq!(off.warnings, on.warnings); + assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); +} + +#[test] +fn images_can_be_left_out_with_their_files() { + let d = image_and_fill_doc(Paint::None); + let out = export(&d, &DxfOptions { include_images: false, ..opts(&d) }).unwrap(); + assert!(entities(&out).is_empty() && out.images.is_empty(), "{:?}", kinds(&out)); + let p = pairs(&out.bytes); + assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); + assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); + assert_eq!(out.warnings, [IMAGES_OFF]); + // An image that can't be decoded is not read when images are left out. + let mut broken = d.clone(); + broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); + assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); + // R12 has no images anyway: its own note alone. + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); +} + +#[test] +fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both() { + let d = image_and_fill_doc(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let run = |o: DxfOptions| export(&d, &DxfOptions { region: d.artboards[0].rect, ..o }).unwrap(); + let both = run(DxfOptions::default()); + assert_eq!(kinds(&both), ["LWPOLYLINE", "HATCH", "IMAGE"]); + assert_eq!(both.images.len(), 1); + let no_images = run(DxfOptions { include_images: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_images), no_images.images.len()), (vec!["LWPOLYLINE".to_string(), "HATCH".into()], 0)); + let no_hatches = run(DxfOptions { include_hatches: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_hatches), no_hatches.images.len()), (vec!["LWPOLYLINE".to_string(), "IMAGE".into()], 1)); + // CNC / CAM: the cutting path alone, with no image file and both notes. + let cnc = run(DxfPreset::Cnc.options()); + assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); + assert!(cnc.images.is_empty()); + assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + let custom = DxfPreset::Custom.options(); + assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index b63447e228..16cf3b2631 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export DXF", [], None, - "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] @@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "dxfPreset", ty: "string", default: "\"custom\"", - description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it", }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { @@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "\"png\"", description: "png or jpeg: the files placed images are written to, next to the drawing", }, + FormatOption { + name: "includeImages", + ty: "boolean", + default: "true", + description: "placed images are written, linked to their files; false leaves them and their files out", + }, + FormatOption { + name: "includeHatches", + ty: "boolean", + default: "true", + description: "filled areas get a solid hatch after their outlines; false writes the outlines alone", + }, FormatOption { name: "preserve", ty: "string", @@ -133,6 +145,8 @@ struct Params { scale_lineweights: Option<bool>, colors: Option<Value>, raster_format: Option<String>, + include_images: Option<bool>, + include_hatches: Option<bool>, preserve: Option<String>, alter_paths: Option<bool>, outline_text: Option<bool>, @@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?, raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?, + include_images: q.include_images.unwrap_or(d.include_images), + include_hatches: q.include_hatches.unwrap_or(d.include_hatches), preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?, alter_paths: q.alter_paths.unwrap_or(d.alter_paths), outline_text: q.outline_text.unwrap_or(d.outline_text), diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 8497676836..54839edda4 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() { true ) ); + assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches"); + let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap(); + assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it"); + let custom = dxf::options(&json!({})).unwrap(); + assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both"); assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); assert!(e.contains("custom or cnc"), "{e}"); @@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill +/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves +/// out both, and the options given override it. +#[test] +fn placed_images_and_fill_hatches_can_be_left_out() { + let mut s = super::tests_svg::image_session(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap(); + let counts = |r: &Value| { + let e = entities(&text(r)); + let n = |k: &str| e.iter().filter(|x| *x == k).count(); + (n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len)) + }; + let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w))); + let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + assert_eq!(counts(&all), (1, 1, 1, 1)); + let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap(); + assert_eq!(counts(&r), (1, 1, 0, 0)); + assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap(); + assert_eq!(counts(&r), (1, 0, 0, 0)); + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + // With a path, no image file is written next to the drawing. + let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cut.dxf").to_string_lossy().to_string(); + let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap(); + assert!(r.get("linked").is_none(), "{r}"); + let files: Vec<String> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(files, ["cut.dxf"]); + let _ = std::fs::remove_dir_all(dir); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. @@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() { json!({"rasterFormat": "gif"}), json!({"preserve": "both"}), json!({"alterPaths": "yes"}), + json!({"includeImages": "no"}), + json!({"includeHatches": 0}), ] { let e = s.execute("document.exportDxf", &p).unwrap_err().to_string(); assert!(e.contains("DXF"), "{p}: {e}"); @@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() { "scaleLineweights", "colors", "rasterFormat", + "includeImages", + "includeHatches", "preserve", "alterPaths", "outlineText", diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index cc52623931..498d854fe9 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,8 +1,8 @@ //! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for //! cutting software and shows Custom again once one is changed), version, scale (1 unit of the -//! art = N drawing units) and lineweights, number of colours, the format of placed images, -//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for -//! Appearance and Outline Text. +//! art = N drawing units) and lineweights, number of colours, the format of placed images and +//! Include Placed Images, Preserve Appearance or Maximize Editability, and Export Selected Art +//! Only, Alter Paths for Appearance, Outline Text and Include Fill Hatches. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file @@ -58,6 +58,8 @@ fn preset_fields(preset: DxfPreset) -> Map<String, Value> { ("scaleLineweights", json!(o.scale_lineweights)), ("colors", json!(o.colors.id())), ("rasterFormat", json!(o.raster.id())), + ("includeImages", json!(o.include_images)), + ("includeHatches", json!(o.include_hatches)), ("preserve", json!(o.preserve.id())), ("alterPaths", json!(o.alter_paths)), ("outlineText", json!(o.outline_text)), @@ -140,6 +142,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect(); choice(ui, d, "rasterFormat", tl!("Raster File Format:"), &rasters); + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + check(ui, d, "includeImages", tl!("Include Placed Images")); + }); ui.add_space(10.0); widgets::subheader(ui, tl!("Export Option")); let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default(); @@ -162,6 +168,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if widgets::check(ui, tl!("Outline Text"), appearance || d.bool("outlineText"), !appearance) { d.fields.insert("outlineText".into(), json!(!d.bool("outlineText"))); } + check(ui, d, "includeHatches", tl!("Include Fill Hatches")); ui.add_space(8.0); super::form::caption( ui, diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 22429b1649..775562fa8f 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -50,13 +50,15 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { frame(&mut app); let d = app.ui.dialog.as_ref().unwrap(); let fields = |d: &crate::state::Dialog| { - ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText", "includeImages", "includeHatches"] + .map(|k| d.str(k)) + .join(" ") }; - assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true false false"); // Any change to a field the preset sets shows Custom, and keeps the other fields. set(&mut app, "colors", json!("true")); frame(&mut app); - assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true false false"); set(&mut app, "colors", json!("256")); frame(&mut app); assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); @@ -70,12 +72,12 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); set(&mut app, "dxfPreset", json!("custom")); frame(&mut app); - assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false true true"); // A preset the command gives sets the fields it does not give. app.ui.dialog = None; app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true false false"); // A preset set and confirmed without drawing (the control channel) sets its fields too. set(&mut app, "dxfPreset", json!("cnc")); confirm(&mut app).unwrap(); @@ -84,7 +86,7 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); let d = app.ui.dialog.as_mut().unwrap(); set_field(d, "dxfPreset".into(), json!("cnc")); - assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true false false"); set_field(d, "unit".into(), json!("Inches")); assert_eq!(d.str("dxfPreset"), "custom"); // Setting the preset again sets its fields again. @@ -94,12 +96,51 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { // Setting Custom while Custom is shown still puts back the defaults. let mut reset = d.clone(); set_field(&mut reset, "dxfPreset".into(), json!("custom")); - assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false true true"); confirm(&mut app).unwrap(); let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); } +#[test] +fn include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom() { + let (mut app, written) = app(1); + let shown = |app: &VectorcraftApp| { + let d = app.ui.dialog.as_ref().unwrap(); + (d.str("dxfPreset"), d.bool("includeImages"), d.bool("includeHatches")) + }; + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, true)); + confirm(&mut app).unwrap(); + assert!(text(&written.borrow()[0].1).contains("\nHATCH\n"), "Custom keeps the fill's hatch"); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/b.dxf"})).unwrap(); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + assert_eq!(shown(&app), ("cnc".into(), false, false)); + // Either checkbox changed shows Custom; both back as the preset sets them show CNC / CAM. + set(&mut app, "includeHatches", json!(true)); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), false, true)); + set(&mut app, "includeHatches", json!(false)); + set(&mut app, "includeImages", json!(true)); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, false)); + set(&mut app, "includeImages", json!(false)); + frame(&mut app); + assert_eq!(shown(&app).0, "cnc"); + confirm(&mut app).unwrap(); + assert!(!text(&written.borrow()[1].1).contains("\nHATCH\n"), "CNC / CAM writes the fill's outline alone"); + let remembered = ["includeImages", "includeHatches"].map(|k| app.ui.dxf_options[k].as_bool()); + assert_eq!(remembered, [Some(false), Some(false)]); + // The next DXF Options start from them, and Custom turns both back on. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(shown(&app), ("cnc".into(), false, false)); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, true)); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..b427dfb030 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2561,6 +2561,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растерен ефект Raster File Format: Растерен файлов формат: + Include Placed Images Включване на поставените изображения + Include Fill Hatches Включване на щриховките на запълванията Raster Sample Size: Растерен размер на пробата: Raster/Vector Balance: Баланс растер/вектор: Rasterize Растеризиране diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..5e35852a8c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2346,6 +2346,8 @@ digits Malayalam Malajalam Export Selected Art Only Izvezi samo odabrani crtež Number of Colors: Broj boja: Raster File Format: Format rasterske datoteke: + Include Placed Images Uključi postavljene slike + Include Fill Hatches Uključi šrafure ispuna True colour needs version 2004 or later: the nearest of 256 colours is written. Prava boja zahtijeva verziju 2004 ili noviju: zapisuje se najbliža od 256 boja. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst postaje obrisi, a potezi koje CAD linija ne može nacrtati postaju ispunjeni oblici. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst ostaje tekst, a svaki potez je linija sa svojom debljinom i crticama. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..077eb0a783 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3303,6 +3303,8 @@ digits Malayalam Malaiàlam Range: Interval: Raster Effects Efectes de ràster Raster File Format: Format de fitxer de ràster: + Include Placed Images Inclou les imatges col·locades + Include Fill Hatches Inclou les trames dels emplenaments Raster Sample Size: Mida de mostra de ràster: Raster/Vector Balance: Equilibri ràster/vector: Rasterize Rasteritza diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..834415475c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2374,6 +2374,8 @@ digits Malayalam Malajálamské Export Selected Art Only Exportovat jen vybranou kresbu Number of Colors: Počet barev: Raster File Format: Formát rastrového souboru: + Include Placed Images Zahrnout umístěné obrazy + Include Fill Hatches Zahrnout šrafy výplní True colour needs version 2004 or later: the nearest of 256 colours is written. Plné barvy vyžadují verzi 2004 nebo novější: zapíše se nejbližší z 256 barev. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Text se převede na křivky a tahy, které čára CAD nedokáže vykreslit, se stanou vyplněnými tvary. Type stays text, and every stroke is a line with its lineweight and dashes. Text zůstane textem a každý tah bude čárou s vlastní tloušťkou a čárkováním. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..ad32fecc4d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2178,6 +2178,8 @@ digits Malayalam Malayalam Export Selected Art Only Eksportér kun markeret grafik Number of Colors: Antal farver: Raster File Format: Rasterfilformat: + Include Placed Images Medtag placerede billeder + Include Fill Hatches Medtag skraveringer af udfyldninger True colour needs version 2004 or later: the nearest of 256 colours is written. Ægte farve kræver version 2004 eller senere: den nærmeste af 256 farver skrives. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst bliver til omrids, og konturer, som en CAD-linje ikke kan tegne, bliver til udfyldte figurer. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst forbliver tekst, og hver kontur er en linje med sin linjevægt og streger. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..308e180554 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2592,6 +2592,8 @@ digits Malayalam Malayalam Range: Bereich: Raster Effects Rastereffekte Raster File Format: Pixelbild-Dateiformat: + Include Placed Images Platzierte Bilder einschließen + Include Fill Hatches Füllschraffuren einschließen Raster Sample Size: Aufnahmebereich in Pixelbildern: Raster/Vector Balance: Pixelbild-Vektor-Abgleich: Rasterize In Pixelbild umwandeln diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..9d62d9579f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3279,6 +3279,8 @@ digits Malayalam Μαλαγιάλαμ Randomly change saturation and brightness Τυχαία αλλαγή κορεσμού και φωτεινότητας Raster Effects Εφέ ράστερ Raster File Format: Μορφή αρχείου ράστερ: + Include Placed Images Συμπερίληψη τοποθετημένων εικόνων + Include Fill Hatches Συμπερίληψη διαγραμμίσεων γεμισμάτων Raster Sample Size: Μέγεθος δείγματος ράστερ: Raster/Vector Balance: Ισορροπία ράστερ/διανυσμάτων: Rasterize Unsupported Art Μετατροπή μη υποστηριζόμενου σχεδίου σε ράστερ diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..7a3578e5ea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2634,6 +2634,8 @@ digits Malayalam Malayálam Range: Rango: Raster Effects Efectos de rasterización Raster File Format: Formato de archivo ráster: + Include Placed Images Incluir imágenes colocadas + Include Fill Hatches Incluir sombreados de relleno Raster Sample Size: Tamaño de muestra ráster: Raster/Vector Balance: Equilibrio ráster/vectorial: Rasterize Rasterizar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..c462b9b62a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2333,6 +2333,8 @@ digits Malayalam Malajalam Export Selected Art Only Vie vain valittu kuvitus Number of Colors: Värien määrä: Raster File Format: Rasteritiedoston muoto: + Include Placed Images Sisällytä sijoitetut kuvat + Include Fill Hatches Sisällytä täyttöjen viivoitukset True colour needs version 2004 or later: the nearest of 256 colours is written. Täysväri vaatii version 2004 tai uudemman: käytetään lähintä 256 väristä. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teksti muunnetaan ääriviivoiksi, ja viivat, joita CAD-viiva ei pysty piirtämään, muuttuvat täytetyiksi muodoiksi. Type stays text, and every stroke is a line with its lineweight and dashes. Teksti säilyy tekstinä, ja jokainen viiva on viiva, jolla on oma viivanpaksuutensa ja katkoviivansa. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..687e68c539 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2626,6 +2626,8 @@ digits Malayalam Malayalam Range: Plage : Raster Effects Effets de pixellisation Raster File Format: Format du fichier pixellisé : + Include Placed Images Inclure les images importées + Include Fill Hatches Inclure les hachures des fonds Raster Sample Size: Taille de l’échantillon pixellisé : Raster/Vector Balance: Équilibre pixellisé/vectoriel : Rasterize Pixelliser diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..ed44bea5aa 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3600,6 +3600,8 @@ antiAlias Sharp शार्प Range: रेंज: Raster Effects रीस्टर इफ़ेक्ट Raster File Format: रास्टर फ़ाइल फ़ॉर्मैट: + Include Placed Images रखी गई छवियाँ शामिल करें + Include Fill Hatches फ़िल हैच शामिल करें Raster Sample Size: रास्टर सैंपल साइज़: Raster/Vector Balance: रास्टर/वेक्टर बैलेंस: Rasterize रास्टराइज़ करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..6928f4e079 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3186,6 +3186,8 @@ mark-style Japanese Japán Range: Tartomány: Raster Effects Raszteres effektusok Raster File Format: Raszteres fájlformátum: + Include Placed Images Elhelyezett képek hozzáadása + Include Fill Hatches Kitöltések sraffozásainak hozzáadása Raster Sample Size: Raszteres mintaméret: Raster/Vector Balance: Raszter–vektor arány: Rasterize Raszterizálás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..d3e8d471fc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2178,6 +2178,8 @@ digits Malayalam Malayalam Export Selected Art Only Ekspor hanya karya seni terpilih Number of Colors: Jumlah warna: Raster File Format: Format file raster: + Include Placed Images Sertakan gambar yang ditempatkan + Include Fill Hatches Sertakan arsiran isian True colour needs version 2004 or later: the nearest of 256 colours is written. Warna sejati memerlukan versi 2004 atau lebih baru: yang ditulis adalah warna terdekat dari 256 warna. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teks menjadi garis luar, dan stroke yang tidak dapat digambar oleh garis CAD menjadi bentuk terisi. Type stays text, and every stroke is a line with its lineweight and dashes. Teks tetap berupa teks, dan setiap stroke menjadi garis dengan ketebalan dan pola putus-putusnya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..efd2191f2c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2668,6 +2668,8 @@ digits Malayalam Malayalam Range: Intervallo: Raster Effects Effetti raster Raster File Format: Formato file raster: + Include Placed Images Includi immagini inserite + Include Fill Hatches Includi tratteggi dei riempimenti Raster Sample Size: Dimensione campione raster: Raster/Vector Balance: Bilanciamento raster/vettoriale: Rasterize Rasterizza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..7555d7e258 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2703,6 +2703,8 @@ mark-style Japanese 日本式 Randomly change saturation and brightness 彩度と明度をランダムに変更 Range: 範囲: Raster File Format: ラスターファイル形式: + Include Placed Images 配置画像を含める + Include Fill Hatches 塗りのハッチングを含める Raster Sample Size: ラスターのサンプルサイズ: Raster/Vector Balance: ラスター/ベクターのバランス: Rasterize Unsupported Art 非対応のアートをラスタライズ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..a87c2c79df 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2100,6 +2100,8 @@ digits Malayalam მალაიალამური Export Selected Art Only მხოლოდ მონიშნული ნახატის ექსპორტირება Number of Colors: ფერების რაოდენობა: Raster File Format: რასტრული ფაილის ფორმატი: + Include Placed Images განთავსებული გამოსახულებების ჩართვა + Include Fill Hatches შევსებების დაშტრიხვის ჩართვა True colour needs version 2004 or later: the nearest of 256 colours is written. სრულ ფერს სჭირდება 2004 ან უფრო ახალი ვერსია: იწერება 256 ფერიდან უახლოესი. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. ტექსტი კონტურებად იქცევა, ხოლო ის კონტურები, რომლებსაც CAD-ხაზი ვერ ხატავს, შევსებულ ფიგურებად. Type stays text, and every stroke is a line with its lineweight and dashes. ტექსტი ტექსტად რჩება, ყოველი კონტური კი ხაზია თავისი სისქითა და ტირეებით. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..01416bc746 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2693,6 +2693,8 @@ mark-style Japanese 일본식 Randomly change saturation and brightness 채도와 명도를 무작위로 변경 Range: 범위: Raster File Format: 래스터 파일 형식: + Include Placed Images 배치된 이미지 포함 + Include Fill Hatches 칠 해치 포함 Raster Sample Size: 래스터 샘플 크기: Raster/Vector Balance: 래스터/벡터 균형: Rasterize Unsupported Art 지원되지 않는 아트웍 래스터화 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..f897feba98 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2192,6 +2192,8 @@ digits Malayalam Malayalam Export Selected Art Only Alleen geselecteerde artwork exporteren Number of Colors: Aantal kleuren: Raster File Format: Rasterbestandsindeling: + Include Placed Images Geplaatste afbeeldingen opnemen + Include Fill Hatches Arceringen van vullingen opnemen True colour needs version 2004 or later: the nearest of 256 colours is written. Echte kleur vereist versie 2004 of later: de dichtstbijzijnde van 256 kleuren wordt weggeschreven. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst wordt omlijningen, en lijnen die een CAD-lijn niet kan tekenen, worden gevulde vormen. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst blijft tekst en elke lijn wordt een lijn met zijn eigen lijndikte en streepjes. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..ce556135dc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2289,6 +2289,8 @@ digits Malayalam Malajalam Export Selected Art Only Eksportuj tylko zaznaczoną kompozycję Number of Colors: Liczba kolorów: Raster File Format: Format pliku rastrowego: + Include Placed Images Uwzględnij umieszczone obrazy + Include Fill Hatches Uwzględnij kreskowania wypełnień True colour needs version 2004 or later: the nearest of 256 colours is written. Pełny kolor wymaga wersji 2004 lub nowszej: zapisywany jest najbliższy z 256 kolorów. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst staje się konturami, a obrysy, których linia CAD nie narysuje, stają się wypełnionymi kształtami. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst pozostaje tekstem, a każdy obrys jest linią z grubością i kreskami. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..933279cd53 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2196,6 +2196,8 @@ digits Malayalam Malaiala Range: Intervalo: Raster Effects Efeitos de Raster Raster File Format: Formato de Arquivo Raster: + Include Placed Images Incluir Imagens Inseridas + Include Fill Hatches Incluir Hachuras dos Preenchimentos Raster Sample Size: Tamanho da Amostra Raster: Raster/Vector Balance: Equilíbrio Raster/Vetor: Rasterize Unsupported Art Rasterizar Arte Não Suportada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..8d3cca069a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2663,6 +2663,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растровые эффекты Raster File Format: Формат растрового файла: + Include Placed Images Включать размещенные изображения + Include Fill Hatches Включать штриховку заливок Raster Sample Size: Размер растрового образца: Raster/Vector Balance: Баланс растровых/векторных: Rasterize Растеризовать diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..6abad1337c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2571,6 +2571,8 @@ digits Malayalam Malayalam Range: Omfång: Raster Effects Rastereffekter Raster File Format: Rasterfilformat: + Include Placed Images Inkludera placerade bilder + Include Fill Hatches Inkludera skrafferingar av fyllningar Raster Sample Size: Samplingsstorlek för raster: Raster/Vector Balance: Raster-/vektorbalans: Rasterize Rastrera diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..fc4489090e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2443,6 +2443,8 @@ digits Malayalam Malayalam Range: Aralık: Raster Effects Raster Efektleri Raster File Format: Raster Dosya Formatı: + Include Placed Images Yerleştirilen Görüntüleri Dahil Et + Include Fill Hatches Dolgu Taramalarını Dahil Et Raster Sample Size: Raster Örnek Boyutu: Raster/Vector Balance: Raster/Vektör Dengesi: Rasterize Rasterleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..76ea1fd4f9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2660,6 +2660,8 @@ digits Malayalam Малаялам Range: Діапазон: Raster Effects Растрові ефекти Raster File Format: Формат растрового файлу: + Include Placed Images Включати розміщені зображення + Include Fill Hatches Включати штрихування заливок Raster Sample Size: Розмір растрового зразка: Raster/Vector Balance: Баланс растрових/векторних: Rasterize Растеризувати diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..62b044c576 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2966,6 +2966,8 @@ mark-style Japanese Kiểu Nhật Range: Phạm vi: Raster Effects Hiệu ứng raster Raster File Format: Định dạng tệp raster: + Include Placed Images Bao gồm ảnh đã đặt + Include Fill Hatches Bao gồm mẫu gạch của vùng tô Raster Sample Size: Kích thước mẫu raster: Raster/Vector Balance: Cân bằng raster/vector: Rasterize Raster hóa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..156c535a0f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2536,6 +2536,8 @@ digits Malayalam 马拉雅拉姆数字 Range: 范围: Raster Effects 栅格效果 Raster File Format: 栅格文件格式: + Include Placed Images 包含置入的图像 + Include Fill Hatches 包含填色的图案填充 Raster Sample Size: 栅格取样大小: Raster/Vector Balance: 栅格/矢量平衡: Rasterize 栅格化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..96388df2a4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3630,6 +3630,8 @@ digits Malayalam 馬來亞拉姆數字 Range: 範圍: Raster Effects 點陣效果 Raster File Format: 點陣檔案格式: + Include Placed Images 包含置入的影像 + Include Fill Hatches 包含填色的圖案填充 Raster Sample Size: 點陣取樣大小: Raster/Vector Balance: 點陣/向量平衡: Rasterize 點陣化 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..68a040eab3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -707,8 +707,9 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are `document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, -`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, -`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `includeImages` (Include +Placed Images), `preserve` (`appearance`, `editability`), `selectedOnly`, `alterPaths`, `outlineText`, `includeHatches` +(Include Fill Hatches), plus `path` and the artboard choice. Setting `dxfPreset` to `cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..ac3e1feec1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5863,7 +5863,10 @@ straight paths become polylines (an open path of one straight segment becomes a before 2000), curved ones cubic splines through every anchor, fills their outlines followed by solid hatches (laser and cutter software reads the outlines and skips hatches; R12 has no hatches: the outlines alone), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked -to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). Coordinates are y up +to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). `includeImages: false` +leaves placed images out, with no image entity, no linked file and no decoding of their pixels, and `includeHatches: +false` writes each filled area as its outlines alone, without its solid hatch (both default to `true`; these two +options are VectorCraft's own). Either one, when it leaves something out, adds one note to `warnings`. Coordinates are y up from the bottom-left corner of the first artboard (or `artboard`), in drawing units: `scale` units per `unit` (default 1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or `256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve: @@ -5876,7 +5879,8 @@ selected objects in their layers (every export takes it), and `useArtboards: tru artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: "mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every -stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +stroke a center line), `outlineText: true` (type as paths), `includeImages: false` and `includeHatches: false` (the +cutting paths alone: fills as their outlines, no image files), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, which shows Custom again once a field it sets is changed. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..9b7d84a030 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1500,7 +1500,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes (`fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From c4ace05dbfe9ae1e6e10475bc0251894791aaa78 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:11:09 -0800 Subject: [PATCH 059/126] Graphic Styles: a built-in Painted and Sketched library adds watercolor, charcoal and ink styles The Graphic Styles library menu lists nine libraries. Painted and Sketched follows Hand-Drawn and holds Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink Contour, Wet Ink and Fine Pen. Each style is a solid fill, two with a 1 pt stroke, under one watercolor, dry brush, rough pastels, charcoal, ink outlines, sumi-e or graphic pen effect with every setting given. The library and style names have rows in all 27 catalogs, and docs/mcp.md describes the library. --- crates/doc/src/style_libs.rs | 84 +++++++++++++++++++ crates/engine/src/tests/stylelib.rs | 81 ++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 8 ++ crates/ui-egui/src/i18n/bs.tsv | 8 ++ crates/ui-egui/src/i18n/ca.tsv | 8 ++ crates/ui-egui/src/i18n/cs.tsv | 8 ++ crates/ui-egui/src/i18n/da.tsv | 8 ++ crates/ui-egui/src/i18n/de.tsv | 8 ++ crates/ui-egui/src/i18n/el.tsv | 8 ++ crates/ui-egui/src/i18n/es.tsv | 8 ++ crates/ui-egui/src/i18n/fi.tsv | 8 ++ crates/ui-egui/src/i18n/fr.tsv | 8 ++ crates/ui-egui/src/i18n/hi.tsv | 8 ++ crates/ui-egui/src/i18n/hu.tsv | 8 ++ crates/ui-egui/src/i18n/id.tsv | 8 ++ crates/ui-egui/src/i18n/it.tsv | 8 ++ crates/ui-egui/src/i18n/ja.tsv | 8 ++ crates/ui-egui/src/i18n/ka.tsv | 8 ++ crates/ui-egui/src/i18n/ko.tsv | 8 ++ crates/ui-egui/src/i18n/nl.tsv | 8 ++ crates/ui-egui/src/i18n/pl.tsv | 8 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 ++ crates/ui-egui/src/i18n/ru.tsv | 8 ++ crates/ui-egui/src/i18n/sv.tsv | 8 ++ crates/ui-egui/src/i18n/tests.rs | 14 ++++ crates/ui-egui/src/i18n/tr.tsv | 8 ++ crates/ui-egui/src/i18n/uk.tsv | 8 ++ crates/ui-egui/src/i18n/vi.tsv | 8 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 8 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 8 ++ .../src/panels/tests_style_libraries.rs | 24 ++++++ docs/gaps.md | 9 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 34 files changed, 433 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..19bc888c16 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -155,6 +155,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows }, BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, + BuiltinStyleLibrary { id: "painted-sketched", name: "Painted and Sketched", make: painted_sketched }, BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, @@ -322,6 +323,59 @@ fn hand_drawn() -> Vec<GraphicStyle> { ] } +/// Painted and Sketched: one catalog paint or sketch effect over the whole appearance, so it +/// repaints the fill and the stroke together (watercolor, dry brush, pastels, charcoal and ink). +fn painted_sketched() -> Vec<GraphicStyle> { + vec![ + style( + "Washed Blue", + vec![F(fill("#8ab9c9"))], + vec![fx("artistic.watercolor", json!({"brushDetail": 8.0, "shadowIntensity": 2.0, "texture": 2.0}))], + ), + style( + "Bristle Ochre", + vec![F(fill("#c4975a")), S(stroke("#76533d", 1.0))], + vec![fx("artistic.dryBrush", json!({"brushSize": 4.0, "brushDetail": 7.0, "texture": 2.0}))], + ), + style( + "Pastel Heather", + vec![F(fill("#a78ca5"))], + vec![fx( + "artistic.roughPastels", + json!({ + "strokeLength": 8.0, + "strokeDetail": 9.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 5.0, + "lightDirection": "topLeft", + "invert": false + }), + )], + ), + style( + "Charcoal Shade", + vec![F(fill("#666666"))], + vec![fx("sketch.charcoal", json!({"thickness": 3.0, "detail": 3.0, "lightDarkBalance": 55.0}))], + ), + style( + "Ink Contour", + vec![F(fill("#d9e2df")), S(stroke("#26323b", 1.0))], + vec![fx("brushStrokes.inkOutlines", json!({"strokeLength": 6.0, "darkIntensity": 24.0, "lightIntensity": 8.0}))], + ), + style( + "Wet Ink", + vec![F(fill("#637b85"))], + vec![fx("brushStrokes.sumiE", json!({"strokeWidth": 7.0, "strokePressure": 5.0, "contrast": 20.0}))], + ), + style( + "Fine Pen", + vec![F(fill("#d8d4c6"))], + vec![fx("sketch.graphicPen", json!({"strokeLength": 6.0, "lightDarkBalance": 55.0, "strokeDirection": "rightDiagonal"}))], + ), + ] +} + fn gradient_finishes() -> Vec<GraphicStyle> { let rim = |h: &str| S(stroke(h, 1.0)); vec![ @@ -552,6 +606,36 @@ mod tests { assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); } + #[test] + fn painted_and_sketched_follows_hand_drawn_with_one_effect_per_style() { + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + let at = ids.iter().position(|&id| id == "painted-sketched").expect("listed"); + assert_eq!(ids[at - 1], "hand-drawn"); + let lib = builtin_style_library("painted-sketched").unwrap(); + assert_eq!(lib.name, "Painted and Sketched"); + let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Washed Blue", "Bristle Ochre", "Pastel Heather", "Charcoal Shade", "Ink Contour", "Wet Ink", "Fine Pen"]); + let effects: Vec<&str> = lib.styles.iter().flat_map(|g| g.appearance.effects.iter().map(|e| e.id.as_str())).collect(); + assert_eq!( + effects, + [ + "artistic.watercolor", + "artistic.dryBrush", + "artistic.roughPastels", + "sketch.charcoal", + "brushStrokes.inkOutlines", + "brushStrokes.sumiE", + "sketch.graphicPen" + ] + ); + assert!(lib.styles.iter().all(|g| g.appearance.items.iter().all(|i| i.effects().is_empty())), "no effects on a fill or stroke"); + // No other built-in library holds a style of the same name. + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "painted-sketched") { + let other = builtin_style_library(b.id).unwrap(); + assert!(other.styles.iter().all(|g| !names.contains(&g.name.as_str())), "{}", b.id); + } + } + #[test] fn vcstyles_round_trip() { let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone(); diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 5124fb4502..baceb94a6e 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -82,6 +82,87 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); } +#[test] +fn painted_and_sketched_styles_set_every_effect_setting_and_reopen_applied() { + let mut s = session(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + let at = libs.iter().position(|l| l["id"] == "painted-sketched").expect("listed"); + assert_eq!((libs[at]["name"].as_str(), libs[at]["count"].as_u64()), (Some("Painted and Sketched"), Some(7))); + assert_eq!(libs[at - 1]["id"], "hand-drawn"); + let g = run(&mut s, "graphicStyle.library", json!({"library": "Painted and Sketched"})); + let listed: Vec<(&str, &str, Vec<&str>)> = + g["styles"].as_array().unwrap().iter().map(|x| (x["name"].as_str().unwrap(), x["fill"].as_str().unwrap(), names(&x["effects"]))).collect(); + let want = [ + ("Washed Blue", "#8ab9c9", "artistic.watercolor"), + ("Bristle Ochre", "#c4975a", "artistic.dryBrush"), + ("Pastel Heather", "#a78ca5", "artistic.roughPastels"), + ("Charcoal Shade", "#666666", "sketch.charcoal"), + ("Ink Contour", "#d9e2df", "brushStrokes.inkOutlines"), + ("Wet Ink", "#637b85", "brushStrokes.sumiE"), + ("Fine Pen", "#d8d4c6", "sketch.graphicPen"), + ]; + assert_eq!(listed, want.iter().map(|&(n, f, e)| (n, f, vec![e])).collect::<Vec<_>>()); + // Every setting of each effect is given, with the catalog's key and kind of value. + let (_, lib) = cmd::stylelib::library(&s, "painted-sketched").unwrap(); + for st in &lib.styles { + let e = &st.appearance.effects[0]; + let defaults = vectorcraft_render::effects::effect_info(&e.id).unwrap().defaults; + let (set, all) = (e.params.as_object().unwrap(), defaults.as_object().unwrap()); + let keys = |m: &serde_json::Map<String, Value>| m.keys().cloned().collect::<Vec<_>>(); + assert_eq!(keys(set), keys(all), "{}", st.name); + for (k, v) in set { + let kind = |v: &Value| (v.is_number(), v.is_string(), v.is_boolean()); + assert_eq!(kind(v), kind(&all[k]), "{} / {k}", st.name); + } + } + let strokes: Vec<(&str, f64)> = + lib.styles.iter().filter(|g| g.appearance.stroke_paint() != Paint::None).map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect(); + assert_eq!(strokes, [("Bristle Ochre", 1.0), ("Ink Contour", 1.0)]); + + // Each one adds and applies to a rectangle in one undo step. + let mut ids = vec![]; + for (i, (name, fill, effect)) in want.iter().enumerate() { + let id = rect(&mut s, 10.0 + 25.0 * i as f64, 20.0, 20.0, 30.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name))); + assert_eq!(undo_len(&s), before + 1, "{name}"); + let node = doc(&s).node(id).unwrap(); + assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex(fill).unwrap()), "{name}"); + assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [*effect]); + assert_eq!(node.appearance.effects[0].params, lib.style(name).unwrap().appearance.effects[0].params, "{name}"); + ids.push((id, *name)); + } + // Adding one again reuses it: no new style, no undo step. + let before = (undo_len(&s), doc(&s).graphic_styles.len()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink"})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Wet Ink"])); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + // An unknown style or library changes nothing. + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-sketched", "names": ["Wet Ink", "Nope"]})).is_err()); + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-and-sketched", "name": "Wet Ink"})).is_err()); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + + // Saved and opened again, the styles keep their effects and the rectangles stay linked to them. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "painted.vectorcraft", "dataBase64": b64})); + for (id, name) in &ids { + let d = doc(&s); + let style = d.graphic_style(name).unwrap(); + assert!(style.same_look(lib.style(name).unwrap()), "{name}"); + let node = d.node(*id).unwrap(); + assert_eq!((node.extras().graphic_style, &node.appearance.effects), (Some(style.id), &style.appearance.effects), "{name}"); + } + + // With nothing selected, `apply` only adds; empty `names` adds the whole library in order. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Fine Pen", "apply": true})); + assert!(r.get("applied").is_none() && doc(&s).graphic_style("Fine Pen").is_some()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "names": []})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (want[..6].iter().map(|w| w.0).collect::<Vec<_>>(), vec!["Fine Pen"])); +} + #[test] fn add_dedupes_names_as_one_undo_step() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..62f130dd16 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1398,6 +1398,14 @@ font-technology Variable Променливи Hair Space Тънък интервал Hand Tool Инструмент „Ръка“ Hand-Drawn Ръчно рисувани + Painted and Sketched Рисувани и скицирани + Washed Blue Размито синьо + Bristle Ochre Охра с четка + Pastel Heather Пастелен пирен + Charcoal Shade Сянка с въглен + Ink Contour Мастилен контур + Wet Ink Мокро мастило + Fine Pen Фино перо Handles Дръжки Hard Light Твърда светлина Hard Mix Твърдо смесване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..142af7344e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -795,6 +795,14 @@ Shield Buff Štit Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano + Painted and Sketched Slikano i skicirano + Washed Blue Isprano plavo + Bristle Ochre Oker kistom + Pastel Heather Pastelni vrijesak + Charcoal Shade Sjena ugljenom + Ink Contour Kontura tušem + Wet Ink Mokri tuš + Fine Pen Fino pero Gradient Finishes Gradijentne završnice Shape Effects Efekti oblika Blends and Transparency Stapanja i prozirnost diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..9d852140cd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1905,6 +1905,14 @@ digits Malayalam Malaiàlam Hand Mà Hand Tool Eina de mà Hand-Drawn Dibuixat a mà + Painted and Sketched Pintat i esbossat + Washed Blue Blau rentat + Bristle Ochre Ocre de cerres + Pastel Heather Bruc pastel + Charcoal Shade Ombra de carbó + Ink Contour Contorn de tinta + Wet Ink Tinta humida + Fine Pen Ploma fina Handles Nanses Hard Dur Hard Light Llum dura diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..7fd853134f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -803,6 +803,14 @@ Shield Buff Štít Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené + Painted and Sketched Malované a skicované + Washed Blue Rozmytá modrá + Bristle Ochre Okr štětcem + Pastel Heather Pastelový vřes + Charcoal Shade Stín uhlem + Ink Contour Obrys tuší + Wet Ink Mokrá tuš + Fine Pen Jemné pero Gradient Finishes Přechodové povrchy Shape Effects Efekty tvarů Blends and Transparency Prolnutí a průhlednost diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..2c53139bf8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -741,6 +741,14 @@ Shield Buff Skjold Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet + Painted and Sketched Malet og skitseret + Washed Blue Udvasket blå + Bristle Ochre Børsteokker + Pastel Heather Pastellyng + Charcoal Shade Kulskygge + Ink Contour Blækkontur + Wet Ink Vådt blæk + Fine Pen Fin pen Gradient Finishes Gradientfinish Shape Effects Formeffekter Blends and Transparency Blandinger og gennemsigtighed diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..2ac8ef3851 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1435,6 +1435,14 @@ file-filter Templates Vorlagen Hair Space Haarspatium Hand Tool Hand-Werkzeug Hand-Drawn Handgezeichnet + Painted and Sketched Gemalt und skizziert + Washed Blue Verwaschenes Blau + Bristle Ochre Borstenocker + Pastel Heather Pastellheide + Charcoal Shade Kohleschatten + Ink Contour Tuschekontur + Wet Ink Nasse Tusche + Fine Pen Feine Feder Handles Griffe Hard Light Hartes Licht Hard Mix Hart mischen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..386ec443d5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1373,6 +1373,14 @@ font-technology Variable Μεταβλητές Hair Space Πολύ λεπτό διάστημα Hand Tool Χέρι (Hand Tool) Hand-Drawn Σχεδιασμένο στο χέρι + Painted and Sketched Ζωγραφισμένο και σκιτσαρισμένο + Washed Blue Ξεπλυμένο μπλε + Bristle Ochre Ώχρα με πινέλο + Pastel Heather Παστέλ ρείκι + Charcoal Shade Σκιά με κάρβουνο + Ink Contour Περίγραμμα με μελάνι + Wet Ink Υγρό μελάνι + Fine Pen Λεπτή πένα Handles Λαβές Hard Light Έντονο φως Hard Mix Έντονη ανάμειξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..f432bc4f20 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1452,6 +1452,14 @@ font-technology Variable Variables Hair Space Espacio fino Hand Tool Herramienta Mano Hand-Drawn Dibujado a mano + Painted and Sketched Pintado y bocetado + Washed Blue Azul lavado + Bristle Ochre Ocre de cerdas + Pastel Heather Brezo pastel + Charcoal Shade Sombra al carboncillo + Ink Contour Contorno de tinta + Wet Ink Tinta húmeda + Fine Pen Plumilla fina Handles Manejadores Hard Light Luz fuerte Hard Mix Mezcla definida diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..98154222f2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -794,6 +794,14 @@ Shield Buff Kilpi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt + Painted and Sketched Maalatut ja luonnostellut + Washed Blue Haalea sininen + Bristle Ochre Harjasokra + Pastel Heather Pastellikanerva + Charcoal Shade Hiilivarjo + Ink Contour Mustekontuuri + Wet Ink Märkä muste + Fine Pen Hieno kynä Gradient Finishes Liukuväriviimeistelyt Shape Effects Muototehosteet Blends and Transparency Sulautukset ja läpinäkyvyys diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..a74f882022 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1446,6 +1446,14 @@ font-technology Variable Variables Hair Space Espace ultrafine Hand Tool Outil Main Hand-Drawn Dessiné à la main + Painted and Sketched Peint et esquissé + Washed Blue Bleu lavé + Bristle Ochre Ocre à la brosse + Pastel Heather Bruyère pastel + Charcoal Shade Ombre au fusain + Ink Contour Contour encré + Wet Ink Encre humide + Fine Pen Plume fine Handles Poignées Hard Light Lumière crue Hard Mix Mélange maximal diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..0c6cef82c4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2100,6 +2100,14 @@ antiAlias Sharp शार्प Hand हैंड Hand Tool हैंड टूल Hand-Drawn हाथ-ड्रन + Painted and Sketched पेंट और स्केच + Washed Blue धुला नीला + Bristle Ochre ब्रश गेरू + Pastel Heather पेस्टल बैंगनी + Charcoal Shade चारकोल छाया + Ink Contour स्याही रूपरेखा + Wet Ink गीली स्याही + Fine Pen बारीक पेन Handles हैंडल्स Hard कठोर Hard Light हार्ड लाइट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..5252b197b4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1846,6 +1846,14 @@ file-filter Templates Sablonok Hand Kéz Hand Tool Kéz Hand-Drawn Kézzel rajzolt + Painted and Sketched Festett és vázlatos + Washed Blue Kimosott kék + Bristle Ochre Sörteokker + Pastel Heather Pasztell hanga + Charcoal Shade Szénárnyék + Ink Contour Tuskontúr + Wet Ink Nedves tus + Fine Pen Finom toll Handles Fogantyúk Hard Szigorú Hard Light Kemény fény diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..6163d14a66 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -741,6 +741,14 @@ Shield Buff Perisai Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan + Painted and Sketched Lukisan dan sketsa + Washed Blue Biru luntur + Bristle Ochre Oker kuas kering + Pastel Heather Ungu pastel + Charcoal Shade Bayangan arang + Ink Contour Kontur tinta + Wet Ink Tinta basah + Fine Pen Pena halus Gradient Finishes Finishing gradien Shape Effects Efek bentuk Blends and Transparency Blend dan transparansi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..8b30029541 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1465,6 +1465,14 @@ file-filter Templates Modelli Hair Space Spazio sottilissimo Hand Tool Strumento Mano Hand-Drawn Disegnato a mano + Painted and Sketched Dipinto e schizzato + Washed Blue Blu slavato + Bristle Ochre Ocra a setole + Pastel Heather Erica pastello + Charcoal Shade Ombra a carboncino + Ink Contour Contorno a china + Wet Ink China bagnata + Fine Pen Pennino fine Handles Maniglie Hard Light Luce intensa Hard Mix Miscela intensa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..02ef74f5b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -827,6 +827,14 @@ digits Malayalam マラヤーラム数字 Shield Buff シールド Outlines and Rules アウトラインと罫線 Hand-Drawn 手描き風 + Painted and Sketched 絵画とスケッチ + Washed Blue にじみブルー + Bristle Ochre 筆跡オーカー + Pastel Heather パステルヘザー + Charcoal Shade 木炭シェード + Ink Contour インク輪郭 + Wet Ink 濡れ墨 + Fine Pen 細いペン Gradient Finishes グラデーション仕上げ Shape Effects シェイプ効果 Blends and Transparency ブレンドと透明 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..82453c673b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -700,6 +700,14 @@ Shield Buff ფარი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული + Painted and Sketched დახატული და ესკიზური + Washed Blue გარეცხილი ლურჯი + Bristle Ochre ფუნჯის ოხრა + Pastel Heather პასტელის იისფერი + Charcoal Shade ნახშირის ჩრდილი + Ink Contour მელნის კონტური + Wet Ink სველი მელანი + Fine Pen წვრილი კალამი Gradient Finishes გრადიენტული ზედაპირები Shape Effects ფიგურის ეფექტები Blends and Transparency გადასვლები და გამჭვირვალობა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..6e3c6cfc08 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -820,6 +820,14 @@ digits Malayalam 말라얄람어 Shield Buff 방패 Outlines and Rules 윤곽선과 괘선 Hand-Drawn 손그림 + Painted and Sketched 페인팅과 스케치 + Washed Blue 번진 파랑 + Bristle Ochre 붓결 황토 + Pastel Heather 파스텔 헤더 + Charcoal Shade 목탄 음영 + Ink Contour 잉크 윤곽 + Wet Ink 젖은 먹 + Fine Pen 가는 펜 Gradient Finishes 그레이디언트 마감 Shape Effects 모양 효과 Blends and Transparency 블렌드와 투명도 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..6f0096eb11 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -755,6 +755,14 @@ Shield Buff Schild Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend + Painted and Sketched Geschilderd en geschetst + Washed Blue Verwassen blauw + Bristle Ochre Borsteloker + Pastel Heather Pastelheide + Charcoal Shade Houtskoolschaduw + Ink Contour Inktcontour + Wet Ink Natte inkt + Fine Pen Fijne pen Gradient Finishes Verloopafwerkingen Shape Effects Vormeffecten Blends and Transparency Vermengingen en transparantie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..57a7567e40 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -750,6 +750,14 @@ Shield Buff Tarcza Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie + Painted and Sketched Malowane i szkicowane + Washed Blue Rozmyty błękit + Bristle Ochre Ochra pędzlem + Pastel Heather Pastelowy wrzos + Charcoal Shade Cień węglem + Ink Contour Kontur tuszem + Wet Ink Mokry tusz + Fine Pen Cienkie pióro Gradient Finishes Wykończenia gradientowe Shape Effects Efekty kształtów Blends and Transparency Przejścia i przezroczystość diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..0623cd8151 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1277,6 +1277,14 @@ antiAlias Sharp Nítido Hair Space Espaço Fino Hand Tool Ferramenta Mão Hand-Drawn Desenhado à Mão + Painted and Sketched Pintado e Esboçado + Washed Blue Azul Lavado + Bristle Ochre Ocre de Cerdas + Pastel Heather Urze Pastel + Charcoal Shade Sombra a Carvão + Ink Contour Contorno a Nanquim + Wet Ink Nanquim Úmido + Fine Pen Pena Fina Handles Alças Hard Light Luz Forte Hard Mix Mistura definida diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..1f8ee5beeb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1462,6 +1462,14 @@ file-filter Templates Шаблоны Hair Space Волосяная шпация Hand Tool Инструмент «Рука» Hand-Drawn Нарисованный от руки + Painted and Sketched Живопись и эскизы + Washed Blue Размытый синий + Bristle Ochre Охра кистью + Pastel Heather Пастельный вереск + Charcoal Shade Тень углём + Ink Contour Контур тушью + Wet Ink Влажная тушь + Fine Pen Тонкое перо Handles Маркеры Hard Light Жёсткий свет Hard Mix Жёсткое наложение diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..753a6b44d6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1411,6 +1411,14 @@ font-technology Variable Variabla Hair Space Hårfint blanksteg Hand Tool Handverktyg Hand-Drawn Handritad + Painted and Sketched Målat och skissat + Washed Blue Urtvättad blå + Bristle Ochre Borstockra + Pastel Heather Pastelljung + Charcoal Shade Kolskugga + Ink Contour Tuschkontur + Wet Ink Våt tusch + Fine Pen Fin penna Handles Handtag Hard Light Hårt ljus Hard Mix Hård blandning diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..44ee531d76 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -238,6 +238,20 @@ fn anti_aliasing_method_labels_are_in_complete_catalogs() { } } +/// The Painted and Sketched graphic style library's name and its styles' names have a row in every +/// bundled catalog. +#[test] +fn painted_and_sketched_style_names_are_in_every_catalog() { + let lib = vectorcraft_doc::style_libs::builtin_style_library("painted-sketched").expect("built in"); + let labels: Vec<&str> = std::iter::once(lib.name.as_str()).chain(lib.styles.iter().map(|g| g.name.as_str())).collect(); + assert_eq!(labels.len(), 8); + for lang in LANGUAGES.iter().filter(|lang| !lang.source.is_empty()) { + for label in &labels { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..f0c5cf5788 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1375,6 +1375,14 @@ font-technology Variable Değişken Hair Space Saç Alanı Hand Tool El Aracı Hand-Drawn elle çizilmiş + Painted and Sketched Boyanmış ve Eskiz + Washed Blue Soluk Mavi + Bristle Ochre Fırça Darbeli Toprak Sarısı + Pastel Heather Pastel Funda + Charcoal Shade Kömür Gölge + Ink Contour Mürekkep Kontur + Wet Ink Islak Mürekkep + Fine Pen İnce Kalem Handles Kollar Hard Light Sert Işık Harmony Rules: Uyum Kuralları: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..2e41ccb2c7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1459,6 +1459,14 @@ file-filter Templates Шаблони Hair Space Волосяна шпація Hand Tool Інструмент «Рука» Hand-Drawn Намальований від руки + Painted and Sketched Мальовані та ескізні + Washed Blue Розмитий синій + Bristle Ochre Охра пензлем + Pastel Heather Пастельний верес + Charcoal Shade Тінь вугіллям + Ink Contour Контур тушшю + Wet Ink Мокра туш + Fine Pen Тонке перо Handles Маркери Hard Light Жорстке світло Hard Mix Жорстке накладання diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..2712e53218 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1686,6 +1686,14 @@ file-filter Templates Mẫu Halo Vầng hào quang Hand Tool Công cụ Bàn tay Hand-Drawn Vẽ tay + Painted and Sketched Vẽ màu và phác thảo + Washed Blue Xanh nhạt nhòa + Bristle Ochre Vàng đất cọ khô + Pastel Heather Tím phấn màu + Charcoal Shade Bóng than + Ink Contour Đường viền mực + Wet Ink Mực ướt + Fine Pen Bút mảnh Handles Tay nắm Hard Cứng Hard Light Ánh sáng mạnh diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..7f0c0aad84 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1402,6 +1402,14 @@ file-filter Templates 模板 Hair Space 极细空格 Hand Tool 抓手工具 Hand-Drawn 手绘 + Painted and Sketched 绘画与素描 + Washed Blue 水洗蓝 + Bristle Ochre 干刷土黄 + Pastel Heather 粉彩淡紫 + Charcoal Shade 炭笔阴影 + Ink Contour 墨线轮廓 + Wet Ink 湿墨 + Fine Pen 细笔 Handles 手柄 Hard Light 强光 Hard Mix 实色混合 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..37032585a6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1430,6 +1430,14 @@ file-filter Templates 範本 Hair Space 極細空格 Hand Tool 手形工具 Hand-Drawn 手繪 + Painted and Sketched 繪畫與素描 + Washed Blue 水洗藍 + Bristle Ochre 乾刷土黃 + Pastel Heather 粉彩淡紫 + Charcoal Shade 炭筆陰影 + Ink Contour 墨線輪廓 + Wet Ink 濕墨 + Fine Pen 細筆 Handles 控制把手 Hard Light 實光 Hard Mix 實色混合 diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index a776819381..46c274dee6 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -101,6 +101,30 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style() { + let mut app = app(); + let entry = crate::menus::menu_entries(&app) + .into_iter() + .find(|e| e.path == ["Window", "Graphic Style Libraries"] && e.label == "Painted and Sketched") + .expect("a Window menu item"); + assert_eq!((entry.command.as_deref(), &entry.params["library"]), (Some("window.graphicStyleLibrary"), &json!("painted-sketched"))); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run(entry.command.as_deref().unwrap(), entry.params.clone()).unwrap(); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("graphicStyles", "painted-sketched"))); + let ctx = context(); + let undo = undo_len(&app); + click(&mut app, &ctx, "Charcoal Shade", Modifiers::NONE, 0.0); + let (g, linked) = app.session.selection_graphic_style().expect("applied to the selection"); + assert_eq!((g.name.as_str(), linked), ("Charcoal Shade", true)); + assert_eq!(undo_len(&app), undo + 1, "added and applied as one undo step"); + let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone(); + assert_eq!(node.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["sketch.charcoal"]); + let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1; + let rows = GraphicStyleLibraries::rows(&lib, "ink"); + assert_eq!(rows.iter().map(|r| r.name).collect::<Vec<_>>(), ["Ink Contour", "Wet Ink"]); +} + #[test] fn the_window_menu_and_the_panel_open_every_library() { let mut app = app(); diff --git a/docs/gaps.md b/docs/gaps.md index a228181abb..d7daac6701 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -646,11 +646,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, Eyedropper), for that instance only; - graphic style libraries (Window › Graphic Style Libraries; eight original libraries generated in code, including - Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects - with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other + graphic style libraries (Window › Graphic Style Libraries; nine original libraries generated in code, including + Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, Status Effects + with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff, and Painted and Sketched with + Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink Contour, Wet Ink and Fine Pen; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the nine shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..3cd41ab912 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3353,7 +3353,7 @@ as a value left out. ## Graphic style libraries Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and -Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them +Rules, Hand-Drawn, Painted and Sketched, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? | names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the @@ -3363,6 +3363,13 @@ the patterns the styles paint with come along. `apply: true` also applies the fi does. Library styles keep placed gradients in unit-box space, so each object gets them at the same place relative to its bounds. +Painted and Sketched (`painted-sketched`) holds Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink +Contour, Wet Ink and Fine Pen. Each is a solid fill (Bristle Ochre and Ink Contour add a 1 pt stroke) under one object +effect that repaints fill and stroke together: `artistic.watercolor`, `artistic.dryBrush`, `artistic.roughPastels`, +`sketch.charcoal`, `brushStrokes.inkOutlines`, `brushStrokes.sumiE` and `sketch.graphicPen`, in that order. The +colors and effect settings are VectorCraft's own. These are raster effects, so the document's raster effects +resolution sets how fine they draw. + ```json {"name":"run_command","arguments":{"command":"graphicStyle.library","params":{"library":"hand-drawn"}}} {"name":"run_command","arguments":{"command":"graphicStyle.addFromLibrary","params":{"library":"hand-drawn","name":"Crosshatch","apply":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..09e7620c49 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1354,7 +1354,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eight generated style libraries (including Game HUD and Status Effects) save and load as `.vcstyles`. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; nine generated style libraries (including Game HUD, Status Effects, and Painted and Sketched, whose seven styles each repaint a solid fill with one watercolor, dry brush, pastel, charcoal or ink effect) save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. Applying a style to several objects, and Redefine Graphic Style, give the objects one shared copy of the style's appearance instead of a copy each; an object that is edited later gets its own copy, and an object whose placed gradients are fitted to its own bounds gets its own. The undo history's memory estimate counts a shared appearance once. Saved files are unchanged (`crates/engine/src/tests/styles.rs`). - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). From f82a31045c058934ec379919ab3b8b51dfae9dd4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:11:12 -0800 Subject: [PATCH 060/126] Blend: the Blend panel's Create Spine menu sets whether new blends get a spine The Blend panel gets a Create Spine menu (Auto, Always, Never) under Make, and object.blend.createSpine {mode?} sets or reads it. It is kept with the preferences, is not an undo step and leaves blends already made as they are. object.blend.make takes createSpine to choose for one new blend. Auto and Always give every new blend a straight spine, as before. Never makes a blend without one: its steps sit where a straight spine puts them, object.blend.info reports hasSpine: false, easing is refused and dimmed, Reverse Spine and the spine point commands refuse it, Release gives back no spine path, and Replace Spine gives it a spine. A blend taking more keys keeps whether it has a spine. Native files save noSpine; older files open with their spines. Builds on #5177 by adambelis. --- crates/doc/src/blend.rs | 30 ++- crates/doc/src/live.rs | 2 +- crates/doc/src/live/blend_spec.rs | 63 +++++++ crates/engine/src/cmd/live/blend.rs | 63 ++++++- crates/engine/src/cmd/live/commands.rs | 23 ++- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/blendopts.rs | 176 ++++++++++++++++++ crates/engine/tests/command_sweep.rs | 2 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/blend.rs | 58 ++++-- crates/ui-egui/src/panels/tests_blend.rs | 60 +++++- docs/mcp.md | 22 ++- docs/target-app-parity.md | 1 + 39 files changed, 569 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d6b7eda2c5..05968ced7e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1052,11 +1052,27 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option<Vec<usize>> { /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread /// evenly along it by arc length). +/// +/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> { + if spec.no_spine { + return None; + } + layout_spine(keys, spec) +} + +/// The spine `spec` stores, unless the blend was made without one. +fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { + spec.spine.as_ref().filter(|_| !spec.no_spine) +} + +/// [`blend_spine`] for placing the steps: a blend made without a spine still places them on the +/// straight lines between its key centers. +fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> { if keys.len() < 2 { return None; } - let Some(stored) = &spec.spine else { + let Some(stored) = stored_spine(spec) else { let centers: Vec<Point> = keys.iter().map(|k| center_of(k)).collect(); return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; @@ -1127,10 +1143,10 @@ impl Rail { /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. fn new(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<Self> { let rotate = spec.orientation == BlendOrientation::AlignToPath; - if spec.spine.is_none() && !rotate { + if stored_spine(spec).is_none() && !rotate { return None; } - let (path, anchors) = blend_spine(keys, spec)?; + let (path, anchors) = layout_spine(keys, spec)?; let spine = Spine::new(&path)?; let total = spine.length(); let k = keys.len(); @@ -1192,10 +1208,10 @@ impl Rail { /// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing /// moves. pub(crate) fn rail_reach(keys: &[Arc<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> { - if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + if stored_spine(spec).is_none() && spec.orientation != BlendOrientation::AlignToPath { return None; } - let spine = blend_spine(keys, spec)?.0.bounds()?; + let spine = layout_spine(keys, spec)?.0.bounds()?; let radius = keys .iter() .filter_map(|k| { @@ -1404,7 +1420,9 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> { for j in 1..=n { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; - let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + // Easing needs a spine: a blend made without one keeps its steps evenly spaced. + let eased = rail.is_some() && !spec.no_spine; + let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304c4e70f6..7233f88a50 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -49,7 +49,7 @@ mod expand; mod mesh; mod warp; -pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; +pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, BlendSpineMode}; #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 75df5eee1f..56405e0d18 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -107,6 +107,11 @@ pub struct BlendSpec { /// on anchor points); `None`: chosen so closed shapes don't twist. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub starts: Vec<Option<u32>>, + /// The blend was made without a spine (Create Spine › Never): it has no spine to show, edit, + /// reverse or release, and no easing. Its steps still sit on the straight lines between the + /// key centers. Replace Spine gives it one. + #[serde(default, rename = "noSpine", skip_serializing_if = "is_false", deserialize_with = "lenient_bool")] + pub no_spine: bool, } impl BlendSpec { @@ -116,6 +121,41 @@ impl BlendSpec { } } +/// The Blend panel's Create Spine setting: whether a new blend gets a spine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpineMode { + /// The usual rule: every new blend gets a spine, the straight lines between its key centers. + #[default] + Auto, + /// Every new blend gets a spine, whatever its key objects are. + Always, + /// New blends get no spine. + Never, +} + +impl BlendSpineMode { + /// Every mode, in the order the Blend panel lists them. + pub const ALL: [Self; 3] = [Self::Auto, Self::Always, Self::Never]; + + pub fn id(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } + + pub fn from_id(id: &str) -> Option<Self> { + Self::ALL.into_iter().find(|m| m.id() == id) + } + + /// Whether a new blend made with this mode gets a spine. + pub fn creates_spine(self) -> bool { + self != Self::Never + } +} + /// Blend Options set with no blend selected: what new blends start with. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendDefaults { @@ -123,6 +163,29 @@ pub struct BlendDefaults { pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Create Spine (`object.blend.createSpine`). + #[serde(default, rename = "createSpine", skip_serializing_if = "is_auto", deserialize_with = "spine_mode")] + pub create_spine: BlendSpineMode, +} + +fn is_false(v: &bool) -> bool { + !*v +} + +fn is_auto(v: &BlendSpineMode) -> bool { + *v == BlendSpineMode::Auto +} + +/// Reading a native file never fails over a `noSpine` that is not a boolean: the blend keeps its +/// spine. +fn lenient_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result<bool, D::Error> { + Ok(serde_json::Value::deserialize(d)?.as_bool().unwrap_or(false)) +} + +/// Reading the preferences never fails over an unknown Create Spine value: it becomes Auto. +fn spine_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlendSpineMode, D::Error> { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_str().and_then(BlendSpineMode::from_id).unwrap_or_default()) } /// Reading a native file never fails over one bad blend number: out-of-range values clamp, diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 7d7c21227a..609936ecf7 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -1,5 +1,7 @@ use std::collections::BTreeSet; +use vectorcraft_doc::live::BlendSpineMode; + use super::common::{ DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, spacing_param, starts_param, wrap, @@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc<Node>], old_spec: &BlendSpec, k spec.spine = Some(path); } +/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine. +fn gives_easing(p: &Value) -> bool { + ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) +} + +/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null. +fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result<BlendSpineMode> { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))), + } +} + pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.blend.make"; let roots = roots_param(s, p)?; @@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> { } let starts = starts_param(p, &roots)?; let base = first_blend(&s.doc()?.doc, &roots); + let defaults = s.prefs.blend_options.unwrap_or_default(); + // Create Spine applies to a new blend; one taking more keys keeps whether it has a spine. + let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?; let cur = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, - _ => s.prefs.blend_options.unwrap_or_default(), + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults }, + _ => defaults, }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); - let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), + let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine), + _ => (Default::default(), 0.0, !create_spine.creates_spine()), }; + if no_spine && gives_easing(p) { + return Err(bad(C, "the blend has no spine")); + } let ease = ease_param(p, C, ease0)?; let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { @@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -346,9 +367,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> { // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; - s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d }); return Ok(json!({ "defaults": true })); }; + if gives_easing(p) && any_without_spine(s) { + return Err(bad(C, "the blend has no spine")); + } let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); let ease = ease_param(p, C, cur.ease)?; @@ -384,6 +408,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> { v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>()); + v["hasSpine"] = json!(!spec.no_spine); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) && let Some((path, anchors)) = live::blend_spine(children, &spec) && let Some(sp) = path.subpaths.first() @@ -403,6 +428,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> { }) } +/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads +/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs. +pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "object.blend.createSpine"; + let mut d = s.prefs.blend_options.unwrap_or_default(); + let mode = spine_mode_param(p, C, "mode", d.create_spine)?; + if mode != d.create_spine { + d.create_spine = mode; + s.prefs.blend_options = Some(d); + } + Ok(json!({ "mode": mode.id() })) +} + pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result<Value> { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { @@ -507,6 +545,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> { let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + // A blend made without a spine gets this one. + spec.no_spine = false; spec.spine = Some(spine); spec.key_anchors.clear(); keys_to_spine(children, spec); @@ -517,7 +557,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> { ok() } +/// Is any selected blend one made without a spine? +fn any_without_spine(s: &Session) -> bool { + let Ok(st) = s.doc() else { return false }; + selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) +} + pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> { + if any_without_spine(s) { + return Err(bad("object.blend.reverseSpine", "the blend has no spine")); + } edit_blends(s, "Reverse Spine", |children, spec| { if spec.spine.is_some() && pin(children, spec) { // The keys keep their anchors, which now count from the other end. diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869d..5480f7ae58 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ - blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, - spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, + blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}", has_doc, blend_make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", + "{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> { always, blend_options ), + cmd!( + "object.blend.createSpine", + "Create Spine", + [], + None, + "{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}", + always, + blend_create_spine + ), cmd!( "object.blend.expand", "Expand", @@ -54,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> { "Replace Spine", ["Object", "Blend"], None, - "{} use the selected path as the spine of the selected blend (the path is consumed)", + "{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one", has_blend, blend_replace_spine ), @@ -63,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reverse Spine", ["Object", "Blend"], None, - "{} reverse the order of the key objects along the spine", + "{} reverse the order of the key objects along the spine; an error when a selected blend has no spine", has_blend, blend_reverse_spine ), @@ -72,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 51470e742d..5c03317fa9 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -676,6 +676,7 @@ pub(super) const DENIED: &[&str] = &[ "addon.pen.nextSegment", "effect.refreshDeferred", "graphicStyle.setOptions", + "object.blend.createSpine", "object.blend.options", "object.liquify", "object.pathfinder.options", diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 059357ddb3..4b391c5714 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -418,3 +418,179 @@ fn native_blend_easing_values_clamp_and_non_finite_become_default() { assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); } } + +fn create_spine(s: &mut Session, p: Value) -> Value { + s.execute("object.blend.createSpine", &p).unwrap()["mode"].clone() +} + +/// Two squares 100 pt apart blended with `p`; the blend. +fn make(s: &mut Session, y: f64, p: Value) -> NodeId { + let (a, b) = (rect(s, 0.0, y, 10.0), rect(s, 100.0, y, 10.0)); + let mut p = p; + p["ids"] = json!([a.0, b.0]); + p["steps"] = json!(3); + id_of(&s.execute("object.blend.make", &p).unwrap()) +} + +#[test] +fn create_spine_sets_whether_new_blends_get_a_spine() { + let mut s = session(); + assert_eq!(create_spine(&mut s, json!({})), "auto", "Auto until one is chosen"); + let spined = make(&mut s, 0.0, json!({})); + assert!(!spec(&s, spined).1.no_spine); + let depth = s.doc().unwrap().history.undo.len(); + assert_eq!(create_spine(&mut s, json!({"mode": "never"})), "never"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step"); + assert_eq!(create_spine(&mut s, json!({})), "never"); + // The Blend Options set with nothing selected keep it, and it keeps them. + s.execute("select.none", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"steps": 7})).unwrap(); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(s.prefs.blend_options.unwrap().spacing, BlendSpacing::Steps(7)); + // A new blend has no spine; the blend made before keeps its own. + let bare = make(&mut s, 50.0, json!({})); + assert!(spec(&s, bare).1.no_spine); + assert!(!spec(&s, spined).1.no_spine, "blends already made keep their spine"); + sel(&mut s, &[bare]); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["hasSpine"], json!(false)); + assert!(info.get("spine").is_none(), "{info}"); + sel(&mut s, &[spined]); + assert_eq!(s.execute("object.blend.info", &json!({})).unwrap()["hasSpine"], json!(true)); + // `createSpine` on Make chooses for that blend only. + let forced = make(&mut s, 100.0, json!({"createSpine": "always"})); + assert!(!spec(&s, forced).1.no_spine); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(create_spine(&mut s, json!({"mode": "always"})), "always"); + let always = make(&mut s, 150.0, json!({})); + assert!(!spec(&s, always).1.no_spine); + assert!(spec(&s, bare).1.no_spine, "blends already made keep having no spine"); + let none = make(&mut s, 200.0, json!({"createSpine": "never"})); + assert!(spec(&s, none).1.no_spine); + // The setting survives a round trip through the preferences file; Auto is not written. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Always); + assert_eq!(s.prefs.to_json()["blendOptions"]["createSpine"], json!("always")); + create_spine(&mut s, json!({"mode": "auto"})); + assert!(s.prefs.to_json()["blendOptions"].get("createSpine").is_none()); + let mut odd = s.prefs.to_json(); + odd["blendOptions"]["createSpine"] = json!("sometimes"); + let unknown: Prefs = serde_json::from_value(odd).unwrap(); + assert_eq!(unknown.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Auto, "an unknown value reads as Auto"); +} + +#[test] +fn create_spine_refuses_bad_values_and_changes_nothing() { + let mut s = session(); + create_spine(&mut s, json!({"mode": "never"})); + let prefs = s.prefs.blend_options; + for bad in [json!({"mode": "sometimes"}), json!({"mode": 1}), json!({"mode": true}), json!({"mode": ["never"]}), json!({"mode": "Never"})] { + let e = s.execute("object.blend.createSpine", &bad).unwrap_err().to_string(); + assert!(e.contains("`mode` must be one of auto, always, never"), "{bad}: {e}"); + assert_eq!(s.prefs.blend_options, prefs, "{bad}"); + } + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"createSpine": "x"}), json!({"createSpine": 0}), json!({"createSpine": "never", "easingType": "easeIn"}), json!({"ramp": 30})] + { + let mut p = bad.clone(); + p["ids"] = json!([a.0, b.0]); + assert!(s.execute("object.blend.make", &p).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "{bad}: nothing made"); + } + assert!(s.execute("object.blend.createSpine", &json!({"spine": "never"})).is_err(), "an unknown key"); +} + +#[test] +fn a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release() { + let mut s = session(); + let g = make(&mut s, 0.0, json!({"createSpine": "never"})); + sel(&mut s, &[g]); + let before = node(&s, g); + let depth = s.doc().unwrap().history.undo.len(); + // Reverse Spine, easing and the spine point commands refuse it and change nothing. + let refused = [ + ("object.blend.reverseSpine", json!({})), + ("object.blend.options", json!({"easingType": "easeOut"})), + ("object.blend.options", json!({"ramp": 40})), + ("object.blend.spine.addAnchor", json!({"x": 50, "y": 5})), + ("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 0, "y": 50})), + ]; + for (cmd, p) in refused { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{cmd}: {e}"); + assert_eq!((node(&s, g), s.doc().unwrap().history.undo.len()), (before.clone(), depth), "{cmd}"); + } + // Spacing, Orientation and Shift still apply; Align to Path makes no spine. + s.execute("object.blend.options", &json!({"steps": 2, "orientation": "path", "colorShift": 20})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.spacing, sp.orientation, sp.color_shift, sp.no_spine), (BlendSpacing::Steps(2), BlendOrientation::AlignToPath, 20.0, true)); + assert!(s.execute("object.blend.info", &json!({})).unwrap().get("spine").is_none()); + // Taking in another key keeps it without a spine, whatever Create Spine says. + let c = rect(&mut s, 200.0, 0.0, 10.0); + sel(&mut s, &[g, c]); + create_spine(&mut s, json!({"mode": "always"})); + let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + assert_eq!(spec(&s, g).0.len(), 3); + assert!(spec(&s, g).1.no_spine && spec(&s, g).1.spine.is_none()); + // Release gives back the keys and no spine path. + sel(&mut s, &[g]); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!((r["ids"].as_array().map(Vec::len), &r["spines"]), (Some(3), &json!([]))); + s.execute("edit.undo", &json!({})).unwrap(); + // Replace Spine gives it one: then it reverses and eases like any other. + let line = id_of(&s.execute("path.create", &json!({"d": "M0 100 L200 150"})).unwrap()); + sel(&mut s, &[g, line]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert!(!sp.no_spine && sp.spine.is_some()); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["hasSpine"], &info["spine"]["explicit"]), (&json!(true), &json!(true))); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"easingType": "easeOut", "ramp": 40})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); +} + +#[test] +fn a_blend_without_a_spine_draws_its_steps_where_one_with_a_spine_does() { + let mut s = session(); + for orientation in ["page", "path"] { + let spined = make(&mut s, 0.0, json!({"orientation": orientation})); + let bare = make(&mut s, 0.0, json!({"orientation": orientation, "createSpine": "never"})); + let boxes = |g: NodeId| -> Vec<_> { vectorcraft_doc::live::expand_live(&node(&s, g)).iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(bare), boxes(spined), "{orientation}"); + assert!(boxes(bare).len() >= 3, "the three steps at least"); + } + // Easing stored on a blend without a spine (a hand-edited file) leaves its steps evenly spaced. + let bare = make(&mut s, 300.0, json!({"orientation": "path", "createSpine": "never"})); + let even = vectorcraft_doc::live::expand_live(&node(&s, bare)); + let mut eased = node(&s, bare); + if let NodeKind::Blend { spec, .. } = &mut eased.kind { + spec.ease = vectorcraft_doc::live::BlendEase { kind: vectorcraft_doc::live::BlendEaseType::In, ramp: 1.0 }; + } + let boxes = |v: &[Node]| -> Vec<_> { v.iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(&vectorcraft_doc::live::expand_live(&eased)), boxes(&even)); +} + +#[test] +fn whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one() { + let mut s = session(); + let bare = make(&mut s, 0.0, json!({"createSpine": "never"})); + let spined = make(&mut s, 50.0, json!({})); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + let saved = |g: NodeId| match &back.node(g).unwrap().kind { + NodeKind::Blend { spec, .. } => spec.no_spine, + _ => panic!("not a blend"), + }; + assert!(saved(bare) && !saved(spined)); + assert_eq!(serde_json::to_value(spec(&s, bare).1).unwrap()["noSpine"], json!(true)); + assert!(serde_json::to_value(spec(&s, spined).1).unwrap().get("noSpine").is_none(), "a spine is not written"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(!old.no_spine, "a blend saved before Create Spine has a spine"); + for odd in [json!("yes"), json!(1), json!(null)] { + let v: BlendSpec = serde_json::from_value(json!({"noSpine": odd.clone()})).unwrap_or_else(|e| panic!("{odd}: {e}")); + assert!(!v.no_spine, "{odd}"); + } +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 947cf559d0..b3779d8108 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -674,6 +674,8 @@ fn structured_junk() { ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "createSpine": ["never"], "easingType": "easeIn"})), + ("object.blend.createSpine", json!({"mode": 1e308})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..1482ab04c5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2741,6 +2741,10 @@ digits Malayalam Малаялам Replace Font Замяна на шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Заменете го с друг файл или го игнорирайте: неговите изображения ще продължат да показват записания с документа преглед. Replace Spine Замяна на оста + Create Spine Създаване на ос + Create Spine: Създаване на ос: + Always Винаги + Never Никога Replace Symbol Замяна на символ Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..92366ca639 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -255,6 +255,10 @@ Blend Options… Opcije stapanja… Expand Proširi Replace Spine Zamijeni kičmu + Create Spine Kreiraj kičmu + Create Spine: Kreiraj kičmu: + Always Uvijek + Never Nikad Reverse Spine Obrni kičmu Reverse Front to Back Obrni od naprijed prema nazad Envelope Distort Izobličenje omotnicom diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..fec6d9b52f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3489,6 +3489,10 @@ digits Malayalam Malaiàlam Replace Font Reemplaça el tipus de lletra Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplaça’l per un altre fitxer o ignora’l: les seves imatges continuaran mostrant la previsualització desada amb el document. Replace Spine Reemplaça l’espina + Create Spine Crea l’espina + Create Spine: Crea l’espina: + Always Sempre + Never Mai Replace Symbol Reemplaça el símbol Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..289b225a30 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -261,6 +261,10 @@ Blend Options… Možnosti prolnutí… Expand Rozložit Replace Spine Nahradit páteř + Create Spine Vytvořit páteř + Create Spine: Vytvořit páteř: + Always Vždy + Never Nikdy Reverse Spine Obrátit páteř Reverse Front to Back Obrátit pořadí zepředu dozadu Envelope Distort Deformace obálkou diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..70fa791796 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -247,6 +247,10 @@ Blend Options… Blandingsindstillinger… Expand Udvid Replace Spine Erstat rygrad + Create Spine Opret rygrad + Create Spine: Opret rygrad: + Always Altid + Never Aldrig Reverse Spine Omvend rygrad Reverse Front to Back Omvend fortil og bagtil Envelope Distort Kuvertforvrængning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..3479ec4d4f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2773,6 +2773,10 @@ digits Malayalam Malayalam Replace Font Schrift ersetzen Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersetzen Sie sie durch eine andere Datei oder ignorieren Sie sie: Ihre Bilder zeigen weiterhin die mit dem Dokument gespeicherte Vorschau. Replace Spine Achse ersetzen + Create Spine Achse erstellen + Create Spine: Achse erstellen: + Always Immer + Never Nie Replace Symbol Symbol ersetzen Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..9de4167383 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3426,6 +3426,10 @@ digits Malayalam Μαλαγιάλαμ Replace Font Αντικατάσταση γραμματοσειράς Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Αντικαταστήστε το με άλλο αρχείο ή αγνοήστε το: οι εικόνες του θα συνεχίσουν να εμφανίζουν την προεπισκόπηση που αποθηκεύτηκε στο έγγραφο. Replace Spine Αντικατάσταση κεντρικής καμπύλης + Create Spine Δημιουργία κεντρικής καμπύλης + Create Spine: Δημιουργία κεντρικής καμπύλης: + Always Πάντα + Never Ποτέ Replace Symbol Αντικατάσταση συμβόλου Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..a9db37d416 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2815,6 +2815,10 @@ digits Malayalam Malayálam Replace Font Reemplazar fuente Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplázalo con otro archivo o ignóralo: sus imágenes seguirán mostrando la vista previa guardada con el documento. Replace Spine Reemplazar eje + Create Spine Crear eje + Create Spine: Crear eje: + Always Siempre + Never Nunca Replace Symbol Reemplazar símbolo Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..84c49e161f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -268,6 +268,10 @@ Blend Options… Sulautuksen asetukset… Expand Laajenna Replace Spine Korvaa selkäranka + Create Spine Luo selkäranka + Create Spine: Luo selkäranka: + Always Aina + Never Ei koskaan Reverse Spine Käännä selkäranka Reverse Front to Back Käännä edestä taakse Envelope Distort Vääristyskuori diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..f701ecb2c3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2807,6 +2807,10 @@ digits Malayalam Malayalam Replace Font Remplacer la police Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Remplacez-le par un autre fichier ou ignorez-le : ses images continueront d’afficher l’aperçu enregistré avec le document. Replace Spine Remplacer la dorsale + Create Spine Créer une dorsale + Create Spine: Créer une dorsale : + Always Toujours + Never Jamais Replace Symbol Remplacer le symbole Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..20a6eb3bd8 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3805,6 +3805,10 @@ antiAlias Sharp शार्प Replace Missing Fonts अनुपलब्ध फ़ॉन्ट रिप्लेस करें Replace Missing Fonts… अनुपलब्ध फ़ॉन्ट रिप्लेस करें… Replace Spine स्पाइन रिप्लेस करें + Create Spine स्पाइन बनाएँ + Create Spine: स्पाइन बनाएँ: + Always हमेशा + Never कभी नहीं Replace Symbol सिंबल रिप्लेस करें Reset Transformation रूपांतरण रीसेट करें Replace With Font इस फ़ॉन्ट से रिप्लेस करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..3873afd01b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3355,6 +3355,10 @@ mark-style Japanese Japán Replace Font Betűtípus cseréje Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Cseréld le egy másik fájlra, vagy hagyd ki: a képei a dokumentummal mentett előnézetet mutatják tovább. Replace Spine Gerinc cseréje + Create Spine Gerinc létrehozása + Create Spine: Gerinc létrehozása: + Always Mindig + Never Soha Replace Symbol Szimbólum cseréje Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..81e4472fc7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -247,6 +247,10 @@ Blend Options… Opsi blend… Expand Perluas Replace Spine Ganti spine + Create Spine Buat spine + Create Spine: Buat spine: + Always Selalu + Never Tidak pernah Reverse Spine Balikkan spine Reverse Front to Back Balikkan depan ke belakang Envelope Distort Distorsi amplop diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..011017b892 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2849,6 +2849,10 @@ digits Malayalam Malayalam Replace Font Sostituisci font Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Sostituiscilo con un altro file oppure ignoralo: le sue immagini continueranno a mostrare l'anteprima salvata con il documento. Replace Spine Sostituisci dorsale + Create Spine Crea dorsale + Create Spine: Crea dorsale: + Always Sempre + Never Mai Replace Symbol Sostituisci simbolo Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..84eaeb217b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -289,6 +289,10 @@ digits Malayalam マラヤーラム数字 Blend Options… ブレンドオプション… Expand 拡張 Replace Spine スパインを置き換え + Create Spine スパインを作成 + Create Spine: スパインを作成: + Always 常に + Never しない Reverse Spine スパインを反転 Reverse Front to Back 前後を反転 Envelope Distort エンベロープ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..2adf814857 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -219,6 +219,10 @@ Blend Options… გადასვლის პარამეტრები… Expand გაშლა Replace Spine ხერხემლის ჩანაცვლება + Create Spine ხერხემლის შექმნა + Create Spine: ხერხემლის შექმნა: + Always ყოველთვის + Never არასოდეს Reverse Spine ხერხემლის შებრუნება Reverse Front to Back რიგის შებრუნება წინიდან უკან Envelope Distort გარსით დეფორმაცია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..bb88da4286 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -282,6 +282,10 @@ digits Malayalam 말라얄람어 Blend Options… 블렌드 옵션… Expand 확장 Replace Spine 스파인 바꾸기 + Create Spine 스파인 만들기 + Create Spine: 스파인 만들기: + Always 항상 + Never 안 함 Reverse Spine 스파인 반전 Reverse Front to Back 앞뒤 반전 Envelope Distort 엔벨로프 왜곡 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..f3469d99b3 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -261,6 +261,10 @@ Blend Options… Vermengopties… Expand Uitbreiden Replace Spine Rug vervangen + Create Spine Rug maken + Create Spine: Rug maken: + Always Altijd + Never Nooit Reverse Spine Rug omkeren Reverse Front to Back Van voor naar achter omkeren Envelope Distort Envelopvervorming diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..86bc805fd9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -225,6 +225,10 @@ Blend Options… Opcje przejścia… Expand Rozszerz Replace Spine Zastąp grzbiet + Create Spine Utwórz grzbiet + Create Spine: Utwórz grzbiet: + Always Zawsze + Never Nigdy Reverse Spine Odwróć grzbiet Reverse Front to Back Odwróć kolejność od przodu do tyłu Envelope Distort Zniekształcenie kopertowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..12519f2457 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2351,6 +2351,10 @@ digits Malayalam Malaiala Replace: Substituir: Replace Font Substituir Fonte Replace Spine Substituir Coluna + Create Spine Criar Coluna + Create Spine: Criar Coluna: + Always Sempre + Never Nunca Replace Symbol Substituir Símbolo Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..40c2d374db 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2844,6 +2844,10 @@ digits Malayalam Малаялам Replace Font Заменить шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замените его другим файлом или игнорируйте: его изображения продолжат показывать предварительный просмотр, сохранённый с документом. Replace Spine Заменить остов + Create Spine Создать остов + Create Spine: Создать остов: + Always Всегда + Never Никогда Replace Symbol Заменить символ Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..52f1da61b0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2751,6 +2751,10 @@ digits Malayalam Malayalam Replace Font Ersätt typsnitt Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersätt den med en annan fil eller ignorera den: dess bilder fortsätter att visa förhandsgranskningen som sparats med dokumentet. Replace Spine Ersätt ryggrad + Create Spine Skapa ryggrad + Create Spine: Skapa ryggrad: + Always Alltid + Never Aldrig Replace Symbol Ersätt symbol Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..50059e043f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2620,6 +2620,10 @@ digits Malayalam Malayalam Replace Font Yazı Tipini Değiştir Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Onu başka bir dosyayla değiştirin veya yok sayın: resimleri, belgeyle birlikte kaydedilen önizlemeyi göstermeye devam eder. Replace Spine Omurgayı Değiştir + Create Spine Omurga Oluştur + Create Spine: Omurga Oluştur: + Always Her Zaman + Never Asla Replace Symbol Sembolü Değiştir Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..fe1ad0a93d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2841,6 +2841,10 @@ digits Malayalam Малаялам Replace Font Замінити шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замініть його іншим файлом або ігноруйте: зображення продовжать показувати попередній перегляд, збережений з документом. Replace Spine Замінити вісь + Create Spine Створити вісь + Create Spine: Створити вісь: + Always Завжди + Never Ніколи Replace Symbol Замінити символ Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..0256d827a0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3123,6 +3123,10 @@ mark-style Japanese Kiểu Nhật Replace Font Thay thế phông chữ Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Thay thế bằng tệp khác hoặc bỏ qua: ảnh của nó vẫn hiển thị bản xem trước đã lưu cùng tài liệu. Replace Spine Thay thế trục + Create Spine Tạo trục + Create Spine: Tạo trục: + Always Luôn luôn + Never Không bao giờ Replace Symbol Thay thế ký hiệu Reset Transformation Đặt lại biến đổi Replace With Font Thay thế bằng phông chữ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..ae78e18875 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2715,6 +2715,10 @@ digits Malayalam 马拉雅拉姆数字 Replace Font 替换字体 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 将其替换为其他文件,或忽略:其图像继续显示随文档保存的预览。 Replace Spine 替换脊线 + Create Spine 创建脊线 + Create Spine: 创建脊线: + Always 始终 + Never 从不 Replace Symbol 替换符号 Reset Transformation 重置变换 Replace With Font 替换为字体 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..57fad09f66 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3811,6 +3811,10 @@ digits Malayalam 馬來亞拉姆數字 Replace Font 取代字體 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 以其他檔案取代,或忽略:其影像會繼續顯示隨文件儲存的預覽。 Replace Spine 取代脊線 + Create Spine 建立脊線 + Create Spine: 建立脊線: + Always 一律 + Never 永不 Replace Symbol 取代符號 Reset Transformation 重設變形 Replace With Font 取代為字體 diff --git a/crates/ui-egui/src/panels/blend.rs b/crates/ui-egui/src/panels/blend.rs index e258cfbc09..a41c2946c7 100644 --- a/crates/ui-egui/src/panels/blend.rs +++ b/crates/ui-egui/src/panels/blend.rs @@ -1,13 +1,17 @@ -//! Blend panel (Window → Blend): Make, the Spacing and Orientation of the selected blend (with no -//! blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, -//! Replace Spine, Expand and Release. Each control runs its Object → Blend command -//! (`object.blend.make`, `object.blend.options` and the others), so a change of the selected -//! blend's spacing or orientation shows on the canvas at once as one undo step. Make is dimmed -//! until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two -//! or more objects are selected, and the other buttons are dimmed until a blend is selected. +//! Blend panel (Window → Blend): Make, Create Spine (whether new blends get a spine), the Spacing +//! and Orientation of the selected blend (with no blend selected, the options new blends start +//! with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release. Each control +//! runs its Object → Blend command (`object.blend.make`, `object.blend.createSpine`, +//! `object.blend.options` and the others), so a change of the selected blend's spacing or +//! orientation shows on the canvas at once as one undo step. Make is dimmed until two or more +//! objects are selected. Create Spine is always available. Spacing and Orientation are dimmed until +//! a blend or two or more objects are selected, and the other buttons are dimmed until a blend is +//! selected. Easing and Reverse Spine are dimmed for a blend made without a spine. use egui::Ui; use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_doc::live::BlendSpineMode; use super::selection_len; use crate::VectorcraftApp; @@ -38,10 +42,18 @@ fn buttons(app: &mut VectorcraftApp, ui: &mut Ui, row: &[(&str, &str)]) { }); } -/// Can `cmd` run on the selection? Make needs two or more objects, and the other commands need a -/// selected blend. +/// Can `cmd` run on the selection? Make needs two or more objects, the other commands need a +/// selected blend, and Reverse Spine needs every selected blend to have a spine. fn available(app: &VectorcraftApp, cmd: &str) -> bool { - crate::menus::enabled(app, cmd) && (cmd != "object.blend.make" || selection_len(app) >= 2) + crate::menus::enabled(app, cmd) + && (cmd != "object.blend.make" || selection_len(app) >= 2) + && (cmd != "object.blend.reverseSpine" || !selects_blend_without_spine(app)) +} + +/// Is a blend made without a spine selected? +fn selects_blend_without_spine(app: &VectorcraftApp) -> bool { + let Ok(st) = app.session.doc() else { return false }; + st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) } /// `object.blend.options` parameters for spacing mode `mode`, with the step count and distance @@ -95,8 +107,9 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } }); let selected = info["target"] == "blend"; - // Position easing needs a spine to run along: a replaced one, or Align to Path. - let spined = info["spine"]["explicit"] == true || info["orientation"] == "path"; + // Position easing needs a spine to run along: a replaced one, or Align to Path. A blend made + // without a spine has none. + let spined = info["hasSpine"] != false && (info["spine"]["explicit"] == true || info["orientation"] == "path"); ui.add_enabled_ui(selected && spined, |ui| { let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"]; let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")]; @@ -129,9 +142,30 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Create Spine: whether new blends get a spine. It shows the setting new blends take, whatever is +/// selected, and a change runs `object.blend.createSpine`; blends already made keep theirs. +fn create_spine(app: &mut VectorcraftApp, ui: &mut Ui) { + let current = app.session.prefs.blend_options.unwrap_or_default().create_spine; + let modes = BlendSpineMode::ALL; + let labels = [tl!("Auto"), tl!("Always"), tl!("Never")]; + let shown = modes.iter().position(|m| *m == current).and_then(|i| labels.get(i)).copied().unwrap_or(labels[0]); + let mut change = None; + widgets::label_row(ui, tl!("Create Spine:"), LABEL_W, |ui| { + if let Some(mode) = + widgets::dropdown(ui, "blend-panel-create-spine", shown, &labels, ui.available_width().max(FIELD_W)).and_then(|i| modes.get(i)) + { + change = Some(*mode); + } + }); + if let Some(mode) = change { + app.run("object.blend.createSpine", json!({ "mode": mode.id() })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let blend = crate::menus::enabled(app, "object.blend.release"); buttons(app, ui, &[(tl!("Make"), "object.blend.make")]); + create_spine(app, ui); widgets::divider(ui); ui.add_enabled_ui(blend || selection_len(app) >= 2, |ui| settings(app, ui)); widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| buttons(app, ui, &[(tl!("Reverse Front to Back"), "object.blend.reverseFrontToBack")])); diff --git a/crates/ui-egui/src/panels/tests_blend.rs b/crates/ui-egui/src/panels/tests_blend.rs index b79823237b..46521d3a65 100644 --- a/crates/ui-egui/src/panels/tests_blend.rs +++ b/crates/ui-egui/src/panels/tests_blend.rs @@ -1,8 +1,8 @@ //! The Blend panel drawn headless: Window › Blend opens it, and its controls do nothing until the //! selection allows them. Make needs two or more objects, Spacing and Orientation need a blend or -//! two or more objects, and the other buttons need a blend. Make blends the selection, Spacing and -//! Orientation edit the selected blend through `object.blend.options`, and each other button runs -//! its Object › Blend command. +//! two or more objects, and the other buttons need a blend. Make blends the selection, Create Spine +//! sets whether new blends get a spine, Spacing and Orientation edit the selected blend through +//! `object.blend.options`, and each other button runs its Object › Blend command. use egui::{Event, Pos2, Rect, pos2, vec2}; use serde_json::json; @@ -39,6 +39,11 @@ impl Frames { /// The center of the text `label` the panel draws. fn text(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 { + self.find_text(app, label).unwrap_or_else(|texts| panic!("no {label:?} in {texts:?}")) + } + + /// The center of the text `label` the panel draws, or every text it draws. + fn find_text(&mut self, app: &mut VectorcraftApp, label: &str) -> Result<Pos2, Vec<(String, Rect)>> { self.run(app, vec![]); let out = self.run(app, vec![]); let mut texts = vec![]; @@ -50,7 +55,7 @@ impl Frames { } } out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); - texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")).1.center() + texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).ok_or(texts) } /// The center of the button that screen readers name `label`. @@ -124,7 +129,19 @@ fn window_blend_opens_the_panel() { crate::menus::invoke(&mut app, "window.panel", item.params.clone()); assert!(crate::panel_docking::showing(&app.ui, ID)); let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show_icon_panel(app, ui, ID)); - for label in ["Make", "Spacing:", "Orientation:", "Direction:", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] { + for label in [ + "Make", + "Create Spine:", + "Auto", + "Spacing:", + "Orientation:", + "Direction:", + "Reverse Front to Back", + "Reverse Spine", + "Replace Spine", + "Expand", + "Release", + ] { assert!(text.contains(label), "{label} in {text}"); } assert!(!text.contains("on the roadmap"), "{text}"); @@ -226,6 +243,39 @@ fn each_button_runs_its_blend_command() { assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}"); } +#[test] +fn create_spine_sets_what_new_blends_get_whatever_is_selected() { + let (mut app, id) = blended(); + let (before, depth) = (spec(&app, id), undo_depth(&app)); + let mode = |app: &mut VectorcraftApp| app.session.execute("object.blend.createSpine", &json!({})).unwrap()["mode"].clone(); + let mut f = Frames::new(); + f.text(&mut app, "Create Spine:"); + f.click_text(&mut app, "Auto"); + f.click_text(&mut app, "Never"); + assert_eq!(mode(&mut app), "never"); + assert_eq!((spec(&app, id), undo_depth(&app)), (before, depth), "the selected blend and the undo history stay as they were"); + f.text(&mut app, "Never"); + // Nothing selected: the dropdown still shows and sets the setting. + app.run("select.none", json!({})).unwrap(); + f.click_text(&mut app, "Never"); + f.click_text(&mut app, "Always"); + assert_eq!(mode(&mut app), "always"); + f.click_text(&mut app, "Always"); + f.click_text(&mut app, "Never"); + // A blend made now has no spine: Reverse Spine and easing are dimmed for it. + app.run("edit.undo", json!({})).unwrap(); + app.run("select.all", json!({})).unwrap(); + f.click_text(&mut app, "Make"); + let (g, blend) = top(&app)[0]; + assert!(blend && spec(&app, g).no_spine, "{:?}", top(&app)); + let made = spec(&app, g); + let depth = undo_depth(&app); + f.click_text(&mut app, "Reverse Spine"); + f.click_text(&mut app, "Ease In"); + assert!(f.find_text(&mut app, "Ease Out In").is_err(), "the Type menu did not open"); + assert_eq!((spec(&app, g), undo_depth(&app), app.ui.status.as_str()), (made, depth, ""), "nothing ran"); +} + #[test] fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() { // Type and Ramp need a spine: Align to Path gives the blend one. diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..227c25abc7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8465,7 +8465,8 @@ keys without bounds make 1 step. `starts` gives, per object, the anchor of its f from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). With the Blend tool, `press_key` Escape drops the object clicked first and stops the next click adding to the blend just made; the document and the selection stay as they are. A blend -among the objects takes the others in as more key objects, keeping its options, name and transparency: +among the objects takes the others in as more key objects, keeping its options, name, transparency and whether it +has a spine: ```json {"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[12,15],"starts":[0,2],"steps":8}}} @@ -8473,6 +8474,23 @@ among the objects takes the others in as more key objects, keeping its options, {"name":"run_command","arguments":{"command":"object.blend.info","params":{}}} ``` +`object.blend.createSpine {mode?: "auto" | "always" | "never"}` is the Blend panel's Create Spine menu: whether new +blends get a spine (the [help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) +describes the three choices). Without `mode` it reads the setting; both return `{mode}`. Auto, the default, and Always +give every new blend a straight spine between the key centers, as before, whatever its key objects are. Never makes blends without a spine: their steps sit where a straight spine puts them, and +`object.blend.info` reports `hasSpine: false` and no `spine`. Such a blend has no easing (`easingType` and `ramp` are +refused, and the Blend panel dims Type and Ramp), Reverse Spine and the `object.blend.spine.*` commands refuse it +("the blend has no spine"), and Release gives back its keys with no spine path. Replace Spine gives it a spine. The +setting is kept with the preferences, is not an undo step and does not change blends already made, and +`object.blend.make {createSpine}` chooses for one new blend. Any other value is refused ("`mode` must be one of auto, +always, never") and changes nothing. A blend saved without a spine keeps `noSpine` in the native file; `.ai` and +EPS files hold its drawn steps, as they do for every blend: + +```json +{"name":"run_command","arguments":{"command":"object.blend.createSpine","params":{"mode":"never"}}} +{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[20,31],"createSpine":"always"}}} +``` + With the Blend tool active, `tool.select {tool: "blend"}` finishes the blend, as a click on the tool's button or W does: it drops the object clicked first and stops the next click adding to the blend just made. The document and the selection stay as they are. Choosing any other tool that is already active leaves its work as it is. @@ -8483,7 +8501,7 @@ the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a `handle: "in" | "out"` it places that handle), `object.blend.spine.convertAnchor {anchor, to: "smooth" | "corner", x?, y?}` converts a point's handles (a Smooth drag with `x, y` creates opposite handles; Corner retracts both), `object.blend.spine.reshapeSegment {segment, t, dx, dy, perpendicular?}` bends a segment so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`perpendicular` works as in `path.reshapeSegment`), and `object.blend.spine.removeAnchor {anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release -(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result): +(`object.blend.release`) leaves the spine of a blend that has one as a path that paints nothing (`spines` in the result): ```json {"name":"run_command","arguments":{"command":"object.blend.spine.addAnchor","params":{"id":40,"x":150,"y":100}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..6bab3b9435 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1270,6 +1270,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. + The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. From 44eb3be71301e8973e5f96f2346b925a991d1ee3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:21:45 -0800 Subject: [PATCH 061/126] Scripts: a flattened string stays counted while the next copy is made - the relational operators root both strings while flattening them, so the first copy still counts when the second one starts a collection - join roots its separator for the whole join and each element while it is written, and JSON.stringify roots a string while it writes it - a String object flattens its string when it is made, with the copy counted, so reading one of its characters copies nothing --- crates/js/src/builtins/array.rs | 31 ++++++++++++--------- crates/js/src/builtins/json.rs | 8 +++--- crates/js/src/interp.rs | 13 ++++++--- crates/js/src/string_builtins.rs | 2 ++ crates/js/src/text_tests.rs | 46 +++++++++++++++++++++++++------- 5 files changed, 71 insertions(+), 29 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 263656437c..838fc4ff98 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -279,17 +279,20 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int if len == 0 { return Ok(Value::str("")); } - let flat = flat_charged(interp, &sep)?; - let sep = &flat[..]; - // A result that cannot fit is refused before any element is read. - let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); - if least > (interp.limits.heap_bytes / 4) as u64 { - return Err(Abrupt::Limit(crate::LimitKind::Heap)); - } - interp.join_stack.push(o); - let r = join_elements(interp, o, len, sep, item); - interp.join_stack.pop(); - r + // The separator stays rooted for the whole join, so its flattened copy stays counted. + interp.rooted(&[Value::String(sep.clone())], |interp| { + let flat = flat_charged(interp, &sep)?; + let sep = &flat[..]; + // A result that cannot fit is refused before any element is read. + let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); + if least > (interp.limits.heap_bytes / 4) as u64 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.join_stack.push(o); + let r = join_elements(interp, o, len, sep, item); + interp.join_stack.pop(); + r + }) } fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult<Option<Str>>) -> JsResult<Value> { @@ -302,8 +305,10 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( let Some(s) = item(interp, v)? else { continue }; out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; - let units = flat_charged(interp, &s)?; - out.push_units(interp, &units)?; + interp.rooted(&[Value::String(s.clone())], |interp| { + let units = flat_charged(interp, &s)?; + out.push_units(interp, &units) + })?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish(interp)) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 991495ba9d..364ec99696 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -633,10 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), Value::Number(_) => out.push_str(interp, "null"), - Value::String(s) => { - let units = flat_charged(interp, s)?; - write_quoted(interp, out, &units) - } + Value::String(s) => interp.rooted(std::slice::from_ref(v), |i| { + let units = flat_charged(i, s)?; + write_quoted(i, out, &units) + }), Value::Undefined | Value::Object(_) => Ok(()), } } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afca1a1024..9801cc9047 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2626,7 +2626,11 @@ impl Interp { Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), - Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), + // Flattened now, with its copy counted, so reading a character copies nothing. + Value::String(s) => { + flat_charged(self, s)?; + (Class::String(s.clone()), self.intrinsics.string_proto) + } }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) } @@ -2740,8 +2744,11 @@ impl Interp { (self.to_primitive(x, Hint::Number)?, py) }; if let (Value::String(a), Value::String(b)) = (&px, &py) { - let (a, b) = (flat_charged(self, a)?, flat_charged(self, b)?); - return Ok(Some(*a < *b)); + // Both stay rooted, so the first copy is still counted while the second is made. + return self.rooted(&[px.clone(), py.clone()], |i| { + let (a, b) = (flat_charged(i, a)?, flat_charged(i, b)?); + Ok(Some(*a < *b)) + }); } let nx = self.to_number(&px)?; let ny = self.to_number(&py)?; diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf54..ba9c79155c 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -214,6 +214,8 @@ fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) - if !construct { return Ok(Value::String(s)); } + // Flattened now, with its copy counted, so reading a character copies nothing. + flat_charged(interp, &s)?; let proto = interp.intrinsics.string_proto; Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 3134e318df..03b4fa4339 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -314,15 +314,43 @@ fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; - let not_stopped: Vec<_> = - ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] - .into_iter() - .filter(|read| { - let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); - let result = Realm::with_options(Options::default(), limits.clone()).run(&src); - !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) - }) - .collect(); + let not_stopped: Vec<_> = [ + "X < 'a'", + "X == 1", + "+X", + "X[0]", + "new String(X)[0]", + "({})[{ toString: function () { return X; } }]", + "JSON.stringify(0, null, X)", + "[1].join(X)", + ] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { + // Ropes of 262,145 units, each about half the heap once flat: one fits, two don't. Each is + // made by a function, so nothing but the native code holds it while it is flattened. + let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; }"; + let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; + let run = |read: &str| Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + assert!(run("f('a')[0]").is_ok(), "one flattened copy fits"); + let not_stopped: Vec<_> = [ + "({ valueOf: function () { return f('a'); } }) < ({ valueOf: function () { return f('b'); } })", + "JSON.stringify({ toJSON: function () { return f('a'); } })", + "[1, 2].join({ toString: function () { return f('a'); } })", + "[{ toString: function () { return f('a'); } }].join('')", + ] + .into_iter() + .filter(|read| !matches!(run(read), Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))) + .collect(); assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } From 0ef33f6e41e096f780bb6260a975a0b36a22929d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:24:45 -0800 Subject: [PATCH 062/126] Remove Background: distance mode finds the nearest color before converting In distance mode, each pixel took a square root for every background color. The nearest color is now found by squared distance, and only that distance is converted to a percentage. The levels do not change. --- crates/engine/src/cmd/addons/image.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index 524f6b205e..b2a9d74f83 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -520,7 +520,7 @@ pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], setting /// the tolerance plus the softness on, and in proportion between them. fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { let Settings { tolerance, softness, measure, .. } = *settings; - let d = refs.iter().map(|r| apart(c, *r, measure)).fold(f64::INFINITY, f64::min); + let d = percent(refs.iter().map(|r| gap(c, *r, measure)).fold(f64::INFINITY, f64::min), measure); if d <= tolerance { 0 } else if d >= tolerance + softness { @@ -530,16 +530,22 @@ fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { } } -/// How far apart `a` and `b` are, as a percentage, measured as `measure` says: the RGB distance of -/// the largest one (black to white), or the largest difference on one channel of the channel's range. -/// A difference of `k` 8-bit steps on one channel is exactly `k / 255 × 100` with channels. -fn apart(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { +/// How far apart `a` and `b` are, measured as `measure` says, before `percent` converts it: the +/// squared RGB distance, or the largest difference on one channel. +fn gap(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { match measure { - Match::Distance => { - let sq: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(); - sq.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0 - } - Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) / 65_535.0 * 100.0, + Match::Distance => a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(), + Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)), + } +} + +/// A `gap` as a percentage: of the largest RGB distance (black to white) with distance, or of the +/// channel's range with channels. A difference of `k` 8-bit steps on one channel is exactly +/// `k / 255 × 100` with channels. +fn percent(gap: f64, measure: Match) -> f64 { + match measure { + Match::Distance => gap.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0, + Match::Channels => gap / 65_535.0 * 100.0, } } From 1e70274189999806c45cf574d42f4c7d16cd3930 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:26:11 -0800 Subject: [PATCH 063/126] =?UTF-8?q?Swatch=20libraries:=20Gradients=20?= =?UTF-8?q?=E2=80=BA=20Organic=20Fades=20adds=20soft=20multi-stop=20fades?= =?UTF-8?q?=20that=20thin=20out=20to=20transparent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Gradients gains Organic Fades, a gradient library of fifteen fades (ten linear, five radial). Each runs through three to five unevenly spaced stops with off-center midpoints, mixes with the Perceptual method, and thins out to transparent at the end of a linear fade or at the edge of a radial one. The colors are computed from VectorCraft's own OKLCH anchors. The library also lists in the Swatches panel's library menu, in swatch.library.list (category "gradients") and in the Gradient panel's Presets. swatch.library.add keeps each fade's stops, opacities, midpoints and method in RGB and CMYK documents. The other built-in libraries are unchanged: their gradients still mix with Classic and keep centered midpoints, and a test checks it. - Gradient previews in the Swatch Libraries panel, the Gradient panel's Presets menu, swatch tiles and the toolbar drew every stop opaque. Each chip vertex now takes the sampled opacity as its alpha (freeform points too), and a chip with any translucent vertex draws a white and gray checkerboard under the gradient. Opaque gradients draw as before. - When the preferences are applied, a saved gradient preset whose name matches a built-in preset, or an earlier saved preset, in any case, is renamed to the first free "<name> N" (N from 2), so a saved preset named like a new built-in fade stays listed and can be applied and deleted. --- ROADMAP.md | 1 + crates/color/src/libraries.rs | 185 ++++++++++++++++++++++++- crates/engine/src/cmd/gradpresets.rs | 34 +++++ crates/engine/src/cmd/prefscmds.rs | 4 + crates/engine/src/tests/gradpresets.rs | 53 ++++++- crates/engine/src/tests/swatchlib.rs | 33 +++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/widgets.rs | 62 +++++++++ crates/ui-egui/src/widgets/chips.rs | 36 ++++- docs/target-app-parity.md | 2 +- 36 files changed, 427 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 327036c371..3e8cfb9d34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. +- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index e96760d5c5..5b5fd3653a 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, OnceLock}; use serde::{Deserialize, Serialize}; -use crate::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; +use crate::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; /// A library: its name, its ungrouped swatches and its colour groups. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -72,6 +72,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[ lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients), lib!("sky-gradients", "Sky Gradients", sky_gradients), lib!("neutral-gradients", "Neutral Gradients", neutral_gradients), + lib!("organic-fades", "Organic Fades", organic_fades), lib!("duotone-gradients", "Duotone Gradients", duotone_gradients), lib!("radial-glows", "Radial Glows", radial_glows), ]; @@ -179,6 +180,14 @@ pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32 Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false } } +/// A gradient swatch mixed by `method` through `stops` of (offset, color, opacity, midpoint). +fn gradient_with(name: &str, kind: GradientKind, method: GradientMethod, stops: &[(f32, Color, f32, f32)]) -> Swatch { + let stops = + stops.iter().map(|&(offset, color, opacity, midpoint)| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }).collect(); + let gradient = Gradient { method, ..Gradient::new(kind, stops) }; + Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(gradient))), global: false, spot: false } +} + /// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1), /// named "`name` 1"… fn ramp(name: &str, n: usize, (l0, l1): (f64, f64), c: impl Fn(f64) -> f64, h: impl Fn(f64) -> f64) -> SwatchGroup { @@ -387,6 +396,141 @@ fn radial_glows() -> (Vec<Swatch>, Vec<SwatchGroup>) { (s, vec![]) } +/// A stop of [`organic_fades`]: offset, OKLCH `(l, c, h°)`, opacity and midpoint. +type FadeStop = (f32, (f64, f64, f64), f32, f32); + +/// Soft fades through a few unevenly spaced stops with off-center midpoints, mixed perceptually, +/// that thin out to transparent: at the end of the linear ones and at the edge of the radial ones. +fn organic_fades() -> (Vec<Swatch>, Vec<SwatchGroup>) { + use GradientKind::{Linear, Radial}; + let fades: [(&str, GradientKind, &[FadeStop]); 15] = [ + ( + "Mist", + Linear, + &[ + (0.0, (0.94, 0.012, 240.0), 1.0, 0.38), + (0.35, (0.86, 0.03, 235.0), 0.85, 0.62), + (0.7, (0.8, 0.035, 230.0), 0.4, 0.45), + (1.0, (0.78, 0.035, 230.0), 0.0, 0.5), + ], + ), + ( + "Petal", + Linear, + &[ + (0.0, (0.82, 0.09, 350.0), 1.0, 0.42), + (0.28, (0.88, 0.07, 10.0), 0.9, 0.66), + (0.64, (0.93, 0.04, 30.0), 0.45, 0.35), + (1.0, (0.95, 0.03, 40.0), 0.0, 0.5), + ], + ), + ("Fern", Linear, &[(0.0, (0.52, 0.1, 145.0), 1.0, 0.6), (0.4, (0.68, 0.11, 135.0), 0.75, 0.4), (1.0, (0.85, 0.08, 120.0), 0.0, 0.5)]), + ( + "Smoke", + Linear, + &[ + (0.0, (0.35, 0.01, 260.0), 1.0, 0.3), + (0.22, (0.48, 0.012, 255.0), 0.8, 0.58), + (0.58, (0.62, 0.01, 250.0), 0.35, 0.7), + (1.0, (0.72, 0.008, 250.0), 0.0, 0.5), + ], + ), + ( + "Honey", + Linear, + &[ + (0.0, (0.72, 0.15, 70.0), 1.0, 0.55), + (0.3, (0.8, 0.14, 80.0), 0.9, 0.35), + (0.75, (0.9, 0.09, 90.0), 0.3, 0.6), + (1.0, (0.95, 0.05, 95.0), 0.0, 0.5), + ], + ), + ("Seafoam", Linear, &[(0.0, (0.7, 0.09, 175.0), 1.0, 0.45), (0.45, (0.84, 0.07, 170.0), 0.6, 0.65), (1.0, (0.95, 0.03, 165.0), 0.0, 0.5)]), + ( + "Lavender", + Linear, + &[ + (0.0, (0.62, 0.12, 300.0), 1.0, 0.36), + (0.3, (0.72, 0.1, 295.0), 0.85, 0.55), + (0.55, (0.82, 0.07, 290.0), 0.5, 0.68), + (0.82, (0.9, 0.04, 285.0), 0.15, 0.4), + (1.0, (0.93, 0.03, 285.0), 0.0, 0.5), + ], + ), + ("Driftwood", Linear, &[(0.0, (0.55, 0.04, 60.0), 1.0, 0.64), (0.38, (0.68, 0.035, 70.0), 0.7, 0.42), (1.0, (0.84, 0.025, 80.0), 0.0, 0.5)]), + ( + "Ink Wash", + Linear, + &[ + (0.0, (0.25, 0.05, 265.0), 1.0, 0.28), + (0.18, (0.35, 0.06, 262.0), 0.9, 0.6), + (0.5, (0.5, 0.05, 258.0), 0.45, 0.72), + (1.0, (0.65, 0.04, 255.0), 0.0, 0.5), + ], + ), + ( + "Tide", + Linear, + &[ + (0.0, (0.45, 0.1, 230.0), 1.0, 0.4), + (0.26, (0.6, 0.1, 215.0), 0.8, 0.62), + (0.72, (0.76, 0.08, 200.0), 0.35, 0.44), + (1.0, (0.88, 0.05, 195.0), 0.0, 0.5), + ], + ), + ( + "Bloom", + Radial, + &[ + (0.0, (0.92, 0.05, 40.0), 1.0, 0.4), + (0.22, (0.8, 0.12, 20.0), 0.95, 0.6), + (0.6, (0.7, 0.14, 355.0), 0.4, 0.35), + (1.0, (0.66, 0.13, 345.0), 0.0, 0.5), + ], + ), + ("Dew", Radial, &[(0.0, (0.97, 0.01, 200.0), 1.0, 0.3), (0.3, (0.88, 0.05, 195.0), 0.7, 0.58), (1.0, (0.8, 0.06, 190.0), 0.0, 0.5)]), + ( + "Pollen", + Radial, + &[ + (0.0, (0.96, 0.08, 100.0), 1.0, 0.45), + (0.18, (0.9, 0.15, 95.0), 1.0, 0.65), + (0.55, (0.82, 0.16, 85.0), 0.5, 0.4), + (1.0, (0.76, 0.15, 75.0), 0.0, 0.5), + ], + ), + // A ring: clear at the center, then a band that fades out to the edge. + ( + "Halo", + Radial, + &[ + (0.0, (0.98, 0.005, 90.0), 0.0, 0.6), + (0.42, (0.95, 0.02, 85.0), 0.0, 0.35), + (0.62, (0.92, 0.04, 80.0), 1.0, 0.55), + (1.0, (0.88, 0.05, 75.0), 0.0, 0.5), + ], + ), + ( + "Lichen", + Radial, + &[ + (0.0, (0.8, 0.07, 120.0), 1.0, 0.58), + (0.35, (0.7, 0.08, 130.0), 0.75, 0.4), + (0.74, (0.6, 0.07, 140.0), 0.25, 0.62), + (1.0, (0.55, 0.06, 145.0), 0.0, 0.5), + ], + ), + ]; + let s = fades + .iter() + .map(|&(name, kind, stops)| { + let stops: Vec<(f32, Color, f32, f32)> = stops.iter().map(|&(at, (l, c, h), opacity, mid)| (at, oklch(l, c, h), opacity, mid)).collect(); + gradient_with(name, kind, GradientMethod::Perceptual, &stops) + }) + .collect(); + (s, vec![]) +} + /// The sequential scales of [`perceptual`]: name and anchors of rising lightness. const SEQUENTIAL: [(&str, Anchors); 5] = [ ("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]), @@ -532,6 +676,45 @@ mod tests { let st = &g.gradient.stops; assert!(st.len() >= 2 && st.windows(2).all(|w| w[0].offset <= w[1].offset), "{}", s.name); assert!(st.iter().all(|s| (0.0..=1.0).contains(&s.offset) && (0.0..=1.0).contains(&s.opacity))); + assert!(st.iter().all(|s| (0.01..=0.99).contains(&s.midpoint)), "{}: a midpoint out of range", s.name); + } + } + } + } + + #[test] + fn organic_fades_are_perceptual_multi_stop_fades_to_transparent() { + let lib = builtin_library("organic-fades").unwrap(); + assert_eq!(GRADIENT_LIBRARIES.iter().map(|b| b.name).position(|n| n == "Organic Fades"), Some(3), "after Neutral Gradients"); + assert!(lib.groups.is_empty() && (12..=16).contains(&lib.len()), "{} swatches", lib.len()); + let radial = lib.iter().filter(|s| matches!(&s.paint, Paint::Gradient(g) if g.gradient.kind == GradientKind::Radial)).count(); + assert!(radial >= 3 && radial * 2 <= lib.len(), "{radial} radial of {}", lib.len()); + for s in lib.iter() { + let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) }; + let (g, n) = (&g.gradient, &s.name); + assert_eq!(g.method, GradientMethod::Perceptual, "{n}"); + assert!((3..=5).contains(&g.stops.len()), "{n}: {} stops", g.stops.len()); + assert!(g.stops.windows(2).all(|w| w[0].offset < w[1].offset), "{n}: rising offsets"); + assert_eq!((g.stops[0].offset, g.stops[g.stops.len() - 1].offset), (0.0, 1.0), "{n}"); + // Unevenly spaced: some gap differs from an even step. + let even = 1.0 / (g.stops.len() - 1) as f32; + assert!(g.stops.windows(2).any(|w| (w[1].offset - w[0].offset - even).abs() > 0.02), "{n}: uneven stops"); + // Every segment's midpoint is off center and within the Gradient panel's 13 to 87%. + let segments = &g.stops[..g.stops.len() - 1]; + assert!(segments.iter().all(|st| (st.midpoint - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&st.midpoint)), "{n}: midpoints"); + // Fully opaque somewhere, and clear at the end (the edge of a radial one). + assert!(g.stops.iter().any(|st| st.opacity == 1.0), "{n}: an opaque stop"); + assert_eq!(g.stops[g.stops.len() - 1].opacity, 0.0, "{n}: clear at the end"); + } + } + + #[test] + fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() { + for b in builtins().filter(|b| b.id != "organic-fades") { + for s in builtin_library(b.id).unwrap().iter() { + if let Paint::Gradient(g) = &s.paint { + assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name); + assert!(g.gradient.stops.iter().all(|st| st.midpoint == 0.5), "{}", s.name); } } } diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index 2f69a9df39..d27809fa78 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -109,6 +109,40 @@ fn unlinked(g: &Gradient) -> Gradient { g } +/// Give each saved preset in `saved` a name no built-in preset or earlier saved one has (any case): +/// a clash becomes the first free "<name> N", N from 2. Returns the renames as (old, new). +pub(crate) fn rename_clashing_presets(saved: &mut [GradientPreset]) -> Vec<(String, String)> { + if saved.is_empty() { + return vec![]; + } + let builtin: std::collections::HashSet<String> = builtin_presets().map(|e| e.name.to_ascii_lowercase()).collect(); + let mut taken: std::collections::HashSet<String> = + builtin.iter().cloned().chain(saved.iter().map(|p| p.name.trim().to_ascii_lowercase())).collect(); + let mut kept = std::collections::HashSet::new(); + // The next suffix to try for each name, so many presets with one name take linear time. + let mut next: std::collections::HashMap<String, u64> = std::collections::HashMap::new(); + let mut renamed = vec![]; + for p in saved { + let key = p.name.trim().to_ascii_lowercase(); + if !builtin.contains(&key) && kept.insert(key.clone()) { + continue; + } + let base = p.name.trim(); + let n = next.entry(key).or_insert(2); + let name = loop { + let candidate = format!("{base} {n}"); + *n += 1; + if !taken.contains(&candidate.to_ascii_lowercase()) { + break candidate; + } + }; + taken.insert(name.to_ascii_lowercase()); + kept.insert(name.to_ascii_lowercase()); + renamed.push((std::mem::replace(&mut p.name, name.clone()), name)); + } + renamed +} + impl Session { /// The Gradient panel's presets: the built-in ones by library, then the saved ones. pub fn gradient_presets(&self) -> Vec<GradientPresetEntry> { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a92b4095a9..b053796c91 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -687,6 +687,10 @@ impl Session { for e in self.prefs.keep_folders(&mut p) { log::warn!("{e}"); } + // Saved gradient presets get names no built-in preset or other saved one has. + for (old, new) in super::gradpresets::rename_clashing_presets(&mut p.gradient_presets) { + log::warn!("saved gradient preset {old} is listed as {new}: another preset has its name"); + } // The edit the active tool computes on another thread is applied first (the Preferences // dialog applies its settings here, outside a command). A failure to apply it was the // edit's own, and the preferences apply all the same. diff --git a/crates/engine/src/tests/gradpresets.rs b/crates/engine/src/tests/gradpresets.rs index 437f96d26c..807094a1bd 100644 --- a/crates/engine/src/tests/gradpresets.rs +++ b/crates/engine/src/tests/gradpresets.rs @@ -38,7 +38,10 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() { // Grouped by library, the gradient libraries first, then the swatch libraries holding gradients. let mut libraries: Vec<&str> = rows.iter().map(|r| r["library"].as_str().unwrap()).collect(); libraries.dedup(); - assert_eq!(libraries, ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Duotone Gradients", "Radial Glows", "Metallic Gradients"]); + assert_eq!( + libraries, + ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"] + ); let mut seen = std::collections::HashSet::new(); assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); let vignette = rows.iter().find(|r| r["name"] == "Vignette").unwrap(); @@ -65,6 +68,20 @@ fn applying_a_preset_edits_the_gradient_in_place_as_one_undo_step() { assert_eq!(paint(&s, id, false), before); } +#[test] +fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() { + let (mut s, id) = session_with(json!({"color": "#336699"})); + let mist = s.gradient_preset("Mist").unwrap(); + assert_eq!((mist.library, mist.gradient.method), (Some("Organic Fades"), GradientMethod::Perceptual)); + s.execute("gradient.presets.apply", &json!({"name": "Mist"})).unwrap(); + let g = paint(&s, id, false).gradient; + assert_eq!((g.kind, g.method, &g.stops), (GradientKind::Linear, GradientMethod::Perceptual, &mist.gradient.stops)); + assert_eq!(s.gradient_preset_name(&g).as_deref(), Some("Mist")); + let list = s.execute("gradient.presets.list", &json!({})).unwrap(); + let row = list["presets"].as_array().unwrap().iter().find(|r| r["name"] == "Mist").unwrap().clone(); + assert_eq!(row["gradient"]["method"], "perceptual"); +} + #[test] fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() { let (mut s, id) = session_with(json!({"color": "#336699"})); @@ -334,3 +351,37 @@ fn saved_presets_round_trip_with_the_preferences() { fresh.apply_prefs(back); assert!(fresh.gradient_preset("mine").is_some_and(|e| e.library.is_none())); } + +/// Preferences can hold saved presets named like a built-in preset (saved before it existed) or +/// like each other (edited by hand): applied, each gets a name of its own, any case. +#[test] +fn saved_presets_named_like_a_built_in_or_another_saved_one_get_a_free_name() { + let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}})); + let mist = s.gradient_preset("Mist").unwrap().gradient; + let mut own = paint(&s, id, false).gradient; + own.dither = true; + let preset = |name: &str| crate::cmd::GradientPreset { name: name.into(), gradient: own.clone() }; + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = + vec![preset("mist"), preset("Mine"), preset("MINE"), preset("Mist 2"), preset("Plain"), preset("PLAIN"), preset("plain")]; + s.apply_prefs(prefs); + let saved: Vec<&str> = s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(saved, ["mist 3", "Mine", "MINE 2", "Mist 2", "Plain", "PLAIN 2", "plain 3"]); + let list = s.execute("gradient.presets.list", &json!({})).unwrap(); + let mut seen = std::collections::HashSet::new(); + assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); + // The renamed one applies and deletes; the built-in Mist stays as it was. + s.execute("gradient.presets.apply", &json!({"name": "mist 3"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient, own); + assert_eq!(s.gradient_preset("Mist").unwrap().gradient, mist); + assert_eq!(s.execute("gradient.presets.delete", &json!({"name": "Mist 3"})).unwrap()["deleted"], "mist 3"); + assert!(s.execute("gradient.presets.delete", &json!({"name": "Mist"})).is_err(), "built in"); + assert_eq!(s.prefs.gradient_presets.len(), 6); + // Many presets with one name each get their own number. + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = (0..2000).map(|_| preset("Same")).collect(); + s.apply_prefs(prefs); + let mut seen = std::collections::HashSet::new(); + assert!(s.prefs.gradient_presets.iter().all(|p| seen.insert(p.name.to_lowercase()))); + assert_eq!(s.prefs.gradient_presets.last().map(|p| p.name.as_str()), Some("Same 2000")); +} diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 3ecb5e68e1..55ab34564a 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -638,6 +638,39 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } +#[test] +fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() { + let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap(); + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode})); + let list = run(&mut s, "swatch.library.list", json!({})); + let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "organic-fades").unwrap().clone(); + assert_eq!( + (row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), + (Some("Organic Fades"), Some("gradients"), Some(lib.len() as u64)) + ); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let r = run(&mut s, "swatch.library.add", json!({"library": "organic-fades", "names": ["Petal", "Dew"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Petal", "Dew"]), json!("Petal")), "{mode}"); + for name in ["Petal", "Dew"] { + // The swatch comes as the library has it: stops (colors as given, opacities, midpoints) and method. + let Paint::Gradient(added) = &doc(&s).swatch(name).unwrap().paint else { panic!("{name} is a gradient") }; + let Paint::Gradient(want) = &lib.swatch(name).unwrap().paint else { panic!("{name} is a gradient") }; + assert_eq!(added.gradient, want.gradient, "{mode} {name}"); + assert_eq!(added.gradient.method, vectorcraft_color::GradientMethod::Perceptual); + assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode} {name}: the stops stay RGB"); + assert!(added.gradient.stops.iter().any(|st| st.opacity < 1.0 && (st.midpoint - 0.5).abs() > 0.02), "{mode} {name}"); + } + // The fill takes the fade's method, opacities and midpoints. + let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + let (Paint::Gradient(fill), Paint::Gradient(want)) = (fill, &lib.swatch("Petal").unwrap().paint) else { panic!("a gradient fill") }; + assert_eq!(fill.gradient.method, want.gradient.method, "{mode}"); + let shape = |g: &vectorcraft_color::Gradient| g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect::<Vec<_>>(); + assert_eq!(shape(&fill.gradient), shape(&want.gradient), "{mode}"); + } +} + #[test] fn pattern_libraries_bring_their_patterns_and_add_each_once() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..f4d90e842c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2279,6 +2279,7 @@ font-technology Variable Променливи Options for Selection… Опции за селектиране… Options: Опции: Options… Опции… + Organic Fades Органични преливания or more spaces with a tab или повече интервала с табулация Order: Ред: Ordinals Поредни номера diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..940c0126e9 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -814,6 +814,7 @@ Spectrum Gradients Gradijenti spektra Sky Gradients Gradijenti neba Neutral Gradients Neutralni gradijenti + Organic Fades Organski prelazi Duotone Gradients Duoton gradijenti Radial Glows Radijalni sjajevi Save Swatch Library… Sačuvaj biblioteku uzoraka boja… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..1ca9327d8c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2913,6 +2913,7 @@ digits Malayalam Malaiàlam Options for Selection… Opcions per a la selecció… Options: Opcions: Options… Opcions… + Organic Fades Esvaïments orgànics or more spaces with a tab o més espais amb un tabulador Orange Taronja Order: Ordre: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..8608f0929d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -822,6 +822,7 @@ Spectrum Gradients Spektrální přechody Sky Gradients Přechody oblohy Neutral Gradients Neutrální přechody + Organic Fades Organické přechody Duotone Gradients Dvoutónové přechody Radial Glows Kruhové záře Save Swatch Library… Uložit knihovnu vzorníku… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..44037015c6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -760,6 +760,7 @@ Spectrum Gradients Spektrumgradienter Sky Gradients Himmelgradienter Neutral Gradients Neutrale gradienter + Organic Fades Organiske toninger Duotone Gradients Duotonegradienter Radial Glows Radiale gløder Save Swatch Library… Gem bibliotek med farveprøver… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..e7664ff267 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2306,6 +2306,7 @@ file-filter Templates Vorlagen Options for Selection… Optionen für Auswahl… Options: Optionen: Options… Optionen… + Organic Fades Organische Verläufe or more spaces with a tab oder mehr Leerzeichen durch einen Tabulator Order: Reihenfolge: Ordinals Ordinalzahlen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..05b9f3144d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1840,6 +1840,7 @@ font-technology Variable Μεταβλητές Options: Επιλογές: Options… Επιλογές… Ordinals Τακτικοί αριθμοί + Organic Fades Οργανικές διαβαθμίσεις Orientation Προσανατολισμός Orientation: Προσανατολισμός: Original Πρωτότυπο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..cb934ae2e1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2352,6 +2352,7 @@ font-technology Variable Variables Options for Selection… Opciones de la selección… Options: Opciones: Options… Opciones… + Organic Fades Desvanecidos orgánicos or more spaces with a tab o más espacios por una tabulación Order: Orden: Ordinals Ordinales diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..1b79b87b32 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -813,6 +813,7 @@ Spectrum Gradients Spektriliukuvärit Sky Gradients Taivasliukuvärit Neutral Gradients Neutraalit liukuvärit + Organic Fades Orgaaniset häivytykset Duotone Gradients Kaksisävyliukuvärit Radial Glows Säteittäiset hehkut Save Swatch Library… Tallenna värimallikirjasto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..2639bae89e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2344,6 +2344,7 @@ font-technology Variable Variables Options for Selection… Options de la sélection… Options: Options : Options… Options… + Organic Fades Fondus organiques or more spaces with a tab espaces ou plus par une tabulation Order: Ordre : Ordinals Ordinaux diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..8d072d0dca 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3170,6 +3170,7 @@ antiAlias Sharp शार्प Options for Selection… सिलेक्शन के लिए ऑप्शन्स… Options: ऑप्शन्स: Options… ऑप्शन्स… + Organic Fades ऑर्गेनिक फ़ेड or more spaces with a tab या एक टैब के साथ अधिक रिक्त स्थान Orange नारंगी Order: आदेश: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..9e71e19f97 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3169,6 +3169,7 @@ mark-style Japanese Japán Radial Blur Körkörös elmosás Radial Blur… Körkörös elmosás… Radial Glows Körkörös ragyogások + Organic Fades Organikus áttűnések Radial Gradient Körkörös színátmenet Conic Gradient Kúpos színátmenet Radial Repeat Körkörös ismétlés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..405f9a583f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -760,6 +760,7 @@ Spectrum Gradients Gradien spektrum Sky Gradients Gradien langit Neutral Gradients Gradien netral + Organic Fades Pudar organik Duotone Gradients Gradien duotone Radial Glows Pendar radial Save Swatch Library… Simpan pustaka sampel warna… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..e8999f6fa9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2382,6 +2382,7 @@ file-filter Templates Modelli Options for Selection… Opzioni per la selezione… Options: Opzioni: Options… Opzioni… + Organic Fades Dissolvenze organiche or more spaces with a tab o più spazi con una tabulazione Order: Ordine: Ordinals Ordinali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..5428c35877 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -846,6 +846,7 @@ digits Malayalam マラヤーラム数字 Spectrum Gradients スペクトルグラデーション Sky Gradients 空のグラデーション Neutral Gradients ニュートラルグラデーション + Organic Fades オーガニックフェード Duotone Gradients ダブルトーングラデーション Radial Glows 円形グロー Save Swatch Library… スウォッチライブラリを保存… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..4dd9b37a9e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -719,6 +719,7 @@ Spectrum Gradients სპექტრის გრადიენტები Sky Gradients ცის გრადიენტები Neutral Gradients ნეიტრალური გრადიენტები + Organic Fades ორგანული გაქრობები Duotone Gradients ორტონიანი გრადიენტები Radial Glows რადიალური ნათებები Save Swatch Library… ფერის ნიმუშების ბიბლიოთეკის შენახვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..cbdbc4a709 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -839,6 +839,7 @@ digits Malayalam 말라얄람어 Spectrum Gradients 스펙트럼 그레이디언트 Sky Gradients 하늘 그레이디언트 Neutral Gradients 중간색 그레이디언트 + Organic Fades 유기적 페이드 Duotone Gradients 듀오톤 그레이디언트 Radial Glows 방사형 광선 Save Swatch Library… 견본 라이브러리 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..ad919321da 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -774,6 +774,7 @@ Spectrum Gradients Spectrumverlopen Sky Gradients Luchtverlopen Neutral Gradients Neutrale verlopen + Organic Fades Organische vervagingen Duotone Gradients Duotoonverlopen Radial Glows Radiale gloed Save Swatch Library… Staalenbibliotheek opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..5cd25f4724 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -769,6 +769,7 @@ Spectrum Gradients Gradienty widma Sky Gradients Gradienty nieba Neutral Gradients Gradienty neutralne + Organic Fades Organiczne zanikania Duotone Gradients Gradienty dwutonowe Radial Glows Blaski promieniste Save Swatch Library… Zapisz bibliotekę próbek… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..17507906de 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1949,6 +1949,7 @@ antiAlias Sharp Nítido Options… Opções… Order: Ordem: Ordinals Ordinais + Organic Fades Esmaecimentos Orgânicos Orientation Orientação Orientation: Orientação: Original Original diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..fee37c423d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2377,6 +2377,7 @@ file-filter Templates Шаблоны Options for Selection… Параметры выделения… Options: Параметры: Options… Параметры… + Organic Fades Органические затухания or more spaces with a tab или больше пробелов на табуляцию Order: Порядок: Ordinals Порядковые числительные diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..8a6aedc467 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2291,6 +2291,7 @@ font-technology Variable Variabla Options for Selection… Alternativ för markering… Options: Alternativ: Options… Alternativ… + Organic Fades Organiska toningar or more spaces with a tab eller fler blanksteg med en tabb Order: Ordning: Ordinals Ordningstal diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..d62342f913 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2163,6 +2163,7 @@ shape Open Açık Options for Selection… Seçim Seçenekleri… Options: Seçenekler: Options… Seçenekler… + Organic Fades Organik Solmalar or more spaces with a tab veya sekmeli daha fazla alan Order: Sipariş: Ordinals Sıra Sayıları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..ccd1d07457 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2374,6 +2374,7 @@ file-filter Templates Шаблони Options for Selection… Параметри виділення… Options: Параметри: Options… Параметри… + Organic Fades Органічні згасання or more spaces with a tab або більше прогалин на табуляцію Order: Порядок: Ordinals Порядкові числівники diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..7003b979c6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2950,6 +2950,7 @@ mark-style Japanese Kiểu Nhật Radial Blur… Mờ hướng tâm… Radial Dividers: Đường chia hướng tâm: Radial Glows Ánh sáng hướng tâm + Organic Fades Mờ dần tự nhiên Radial Gradient Chuyển sắc hướng tâm Conic Gradient Chuyển sắc hình nón Radius Bán kính diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..7f6e23476d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2249,6 +2249,7 @@ file-filter Templates 模板 Options 选项 Options: 选项: Options… 选项… + Organic Fades 自然渐隐 or more spaces with a tab 或多个空格加一个制表符 Order: 顺序: Ordinals 序数字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..279dedb1cb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3340,6 +3340,7 @@ file-filter Templates 範本 Options for Selection… 選取範圍的選項… Options: 選項: Options… 選項… + Organic Fades 自然漸隱 or more spaces with a tab 個以上的空格取代為定位字元 Order: 順序: Ordinals 序數字 diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d8914ce39f..adf536f2a9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -754,6 +754,68 @@ mod gradient_chip_tests { assert_eq!(colors[0], Color32::BLACK); } + /// A stop at opacity 0 is clear on the chip of every kind, and one at 50% half clear. + #[test] + fn chip_shows_the_stops_opacity() { + use vectorcraft_color::{Color, GradientStop}; + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic] { + let mut gp = paint(kind, 0.0); + gp.gradient.stops = vec![ + GradientStop { opacity: 0.5, ..GradientStop::new(0.0, Color::BLACK) }, + GradientStop { opacity: 0.0, ..GradientStop::new(1.0, Color::BLACK) }, + ]; + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + let (lo, hi) = chip.iter().fold((u8::MAX, 0), |(lo, hi), c| (lo.min(c.a()), hi.max(c.a()))); + // No grid point of a conic chip lands exactly at the end of its sweep. + assert!(lo <= if kind == GradientKind::Conic { 12 } else { 0 }, "{kind:?}: {chip:?}"); + assert!(hi.abs_diff(128) <= 1, "{kind:?}: {chip:?}"); + } + let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i]; + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.stops[1].opacity = 0.0; + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + assert_eq!(at(&chip, 0, 2), Color32::WHITE); + assert_eq!(at(&chip, 4, 2).a(), 0); + // Radial: an opaque edge around a clear center. + let mut gp = paint(GradientKind::Radial, 0.0); + gp.gradient.stops[0].opacity = 0.0; + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + assert_eq!(at(&chip, 2, 2).a(), 0); + assert_eq!(chip[0], Color32::BLACK); + } + + /// A chip with a clear stop draws a checkerboard under the gradient; an opaque one does not. + #[test] + fn a_clear_gradient_chip_has_a_checkerboard_under_it() { + let ctx = egui::Context::default(); + let shapes = |gp: &GradientPaint| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| gradient_chip(ui, Rect::from_min_size(pos2(10.0, 10.0), vec2(20.0, 20.0)), gp)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|s| s.shape).collect::<Vec<_>>() + }; + let opaque = shapes(&paint(GradientKind::Linear, 0.0)); + assert!(matches!(opaque[..], [egui::Shape::Mesh(_)]), "{opaque:?}"); + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.stops[1].opacity = 0.0; + let clear = shapes(&gp); + let n = clear.len(); + assert!(n > 2 && matches!(clear[n - 1], egui::Shape::Mesh(_)), "{clear:?}"); + let fills: std::collections::HashSet<_> = + clear[..n - 1].iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some(r.fill) } else { None }).collect(); + assert_eq!(fills, [Color32::WHITE, Color32::from_gray(204)].into_iter().collect(), "{clear:?}"); + } + + #[test] + fn freeform_chip_shows_the_points_opacity() { + use vectorcraft_color::{Color, Freeform, FreeformPoint}; + use vectorcraft_geom::kurbo::Point; + let mut gp = paint(GradientKind::Freeform, 0.0); + let clear = FreeformPoint { opacity: 0.0, ..FreeformPoint::new(Point::new(10.0, 10.0), Color::BLACK) }; + gp.freeform = Some(Freeform { points: vec![clear], ..Default::default() }); + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + assert!(chip.iter().all(|c| c.a() == 0), "{chip:?}"); + } + #[test] fn freeform_chip_mixes_points_in_the_model_they_share() { use vectorcraft_color::{Color, Freeform, FreeformPoint}; diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index dca209ce04..017ba588ce 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -39,6 +39,10 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); + // Stops below full opacity show a checkerboard through them. + if colors.iter().any(|c| c.a() < u8::MAX) { + checkerboard(ui, rect); + } let mut mesh = egui::Mesh::default(); for (k, color) in colors.into_iter().enumerate() { let (i, j) = (k % (cols + 1), k / (cols + 1)); @@ -55,14 +59,32 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient ui.painter().add(egui::Shape::mesh(mesh)); } +/// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side +/// (3 to 8 points). +fn checkerboard(ui: &Ui, rect: Rect) { + let p = ui.painter(); + p.rect_filled(rect, 0.0, Color32::WHITE); + let cell = (rect.width().min(rect.height()) / 4.0).clamp(3.0, 8.0); + let cols = (rect.width() / cell).ceil() as usize; + let rows = (rect.height() / cell).ceil() as usize; + for j in 0..rows { + for i in (j % 2..cols).step_by(2) { + let min = pos2(rect.left() + cell * i as f32, rect.top() + cell * j as f32); + let square = Rect::from_min_size(min, Vec2::splat(cell)).intersect(rect); + p.rect_filled(square, 0.0, Color32::from_gray(204)); + } + } +} + /// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` -/// chip showing `gp` as it would look on an object of that size. +/// chip showing `gp` as it would look on an object of that size, with the gradient's opacity as +/// their alpha. pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec<Color32> { use vectorcraft_color::{GradientGeom, GradientKind}; let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); - let rgb = |c: vectorcraft_color::Color| { - let [r, g, b, _] = c.to_rgba8(1.0); - Color32::from_rgb(r, g, b) + let rgba = |(c, a): (vectorcraft_color::Color, f32)| { + let [r, g, b, a] = c.to_rgba8(if a.is_finite() { a } else { 1.0 }); + Color32::from_rgba_unmultiplied(r, g, b, a) }; let at = |k: usize| { let (i, j) = (k % (cols + 1), k / (cols + 1)); @@ -74,9 +96,9 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: let field = gp.freeform_on(painted).model_field(vectorcraft_color::freeform::spread_scale(painted)); return (0..n) .map(|k| { - let ([r, g, b], _) = field.display_sample(at(k)); + let ([r, g, b], a) = field.display_sample(at(k)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(q(r), q(g), q(b)) + Color32::from_rgba_unmultiplied(q(r), q(g), q(b), q(if a.is_finite() { a } else { 1.0 })) }) .collect(); } @@ -90,7 +112,7 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: (0..n) .map(|k| { let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; - rgb(gp.gradient.sample(t).0) + rgba(gp.gradient.sample(t)) }) .collect() } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..60804ae07a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1277,7 +1277,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From a07cdf87f0eb40e49e0f64ddfafbca6f33fade3c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:29:50 -0800 Subject: [PATCH 064/126] PDF/X: names inside a string or a comment no longer fail the standard check The check that a PDF/X-1a or PDF/X-3 file has no transparency read names in literal strings and comments as syntax. The file identifier is a base64 hash of the file, so a file could now and then hold `/BM` or `/Lab` there and its export was refused as having blend modes or Lab colors. The check now leaves out the text of literal strings and comments as well as stream data. --- crates/pdf/src/pdfx.rs | 47 +++++++++++++++++++++++++++--------- crates/pdf/src/tests/pdfx.rs | 20 +++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 116d1cf0c7..e367221d59 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -211,23 +211,46 @@ pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> { Ok(()) } -/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is -/// looked for there isn't found in image or font bytes. +/// `pdf` without its streams' data, the text of its literal strings and its comments (dictionaries +/// and the rest of the syntax), so that what is looked for there isn't found in image or font +/// bytes, or in a string such as the file identifier, whose base64 hash can hold `/BM`. fn without_streams(pdf: &[u8]) -> Vec<u8> { let mut out = Vec::with_capacity(pdf.len().min(1 << 20)); let mut at = 0; - while let Some(s) = find(pdf, b"stream", at) { - let data = s + b"stream".len(); - // `endstream` itself, or a stream keyword that isn't one (inside a name or string). - if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) { - out.extend_from_slice(pdf.get(at..data).unwrap_or_default()); - at = data; - continue; + while let Some(&b) = pdf.get(at) { + match b { + // A literal string: parentheses nest, and a backslash escapes the next byte. + b'(' => { + out.extend_from_slice(b"()"); + let mut depth = 0usize; + at += 1; + while let Some(&c) = pdf.get(at) { + at += 1; + match c { + b'\\' => at += 1, + b'(' => depth += 1, + b')' if depth == 0 => break, + b')' => depth -= 1, + _ => {} + } + } + } + // A comment, to the end of its line. + b'%' => at += pdf.get(at..).unwrap_or_default().iter().take_while(|c| !matches!(c, b'\r' | b'\n')).count(), + // A stream keyword (not `endstream`, and followed by its end of line): its data is left out. + b's' if pdf.get(at..at + 6) == Some(b"stream") + && !(at >= 3 && pdf.get(at - 3..at) == Some(b"end")) + && pdf.get(at + 6).is_some_and(|c| matches!(c, b'\r' | b'\n')) => + { + out.extend_from_slice(b"stream"); + at = find(pdf, b"endstream", at + 6).unwrap_or(pdf.len()); + } + _ => { + out.push(b); + at += 1; + } } - out.extend_from_slice(pdf.get(at..data).unwrap_or_default()); - at = find(pdf, b"endstream", data).unwrap_or(pdf.len()); } - out.extend_from_slice(pdf.get(at..).unwrap_or_default()); out } diff --git a/crates/pdf/src/tests/pdfx.rs b/crates/pdf/src/tests/pdfx.rs index dbc221e0dd..ec22eb0186 100644 --- a/crates/pdf/src/tests/pdfx.rs +++ b/crates/pdf/src/tests/pdfx.rs @@ -224,3 +224,23 @@ fn verify_finds_what_each_standard_forbids() { assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts"))); assert!(verify(&file("<</Type/FontDescriptor/FontFile2 4 0 R>>"), Standard::PdfX4).is_ok()); } + +/// Names inside a string or a comment are text, not syntax: a file identifier, whose base64 hash +/// can hold `/BM` or `/Lab`, does not make a file break its standard. +#[test] +fn verify_skips_names_in_strings_and_comments() { + use crate::pdfx::verify; + let file = |body: &str| format!("%PDF-1.3\n%/BM/Multiply\n1 0 obj\n{body}\nendobj\n").into_bytes(); + for ok in [ + "<</Size 2/ID[(q/BM+Zt1kR0x/Lab=) (q/BM+Zt1kR0x/Lab=)]>>", + "<</Title(a\\)/SMask 5 0 R)/Subject(a(b)/ca 0.5)>>", + "<</Title(stream\n/BM/Multiply\nendstream)>>", + ] { + for standard in [Standard::PdfX1a, Standard::PdfX3] { + assert!(verify(&file(ok), standard).is_ok(), "{standard:?}: {ok}"); + } + } + // A name after a string is still read. + let after = "<</Title(a(b)c)/BM/Multiply>>"; + assert!(matches!(verify(&file(after), Standard::PdfX3), Err(PdfError::Unsupported(m)) if m.contains("blend modes"))); +} From 9fc4e3c5cf71919b117c50a2d7bd2faa9d7d03b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:30:00 -0800 Subject: [PATCH 065/126] =?UTF-8?q?Swatch=20libraries:=20Patterns=20?= =?UTF-8?q?=E2=80=BA=20Grit=20Textures=20adds=20seamless=20grain,=20speckl?= =?UTF-8?q?e=20and=20stipple=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Patterns and the Swatches panel's library menu gain a second built-in pattern library, Grit Textures, with eight black textures on square grid tiles of 18 to 36 pt: Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks. Each is drawn in code from a fixed seed, so it is the same every time. Marks that reach past a tile edge are drawn on the next tile by the tiling, so the tiles join without a seam. Docs: docs/mcp.md, docs/target-app-parity.md, ROADMAP. --- ROADMAP.md | 1 + crates/engine/src/cmd/patternlib.rs | 162 +++++++++++++++++- crates/engine/src/tests/swatchlib.rs | 33 ++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + .../ui-egui/src/panels/library_panel/tests.rs | 3 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 2 +- 33 files changed, 227 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 327036c371..bbc61c3df9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,6 +126,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). +- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 011dfc131c..8fd2a0ea5e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary { } /// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order. -pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = - &[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }]; +pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ + BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, + BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, +]; /// The library category of the built-in pattern libraries. pub const CATEGORY: &str = "patterns"; @@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) { add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]); } +/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same +/// marks every time it is made. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A number from `lo` up to, but not including, `hi`. + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo) + } +} + +/// A round dot of radius `r` at (`x`, `y`). +fn dot(x: f64, y: f64, r: f64) -> PathData { + shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r)) +} + +/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it. +fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData { + use std::f64::consts::TAU; + let n = 4 + (rng.next() % 4) as usize; + let turn = rng.range(0.0, TAU); + let points: Vec<(f64, f64)> = (0..n) + .map(|k| { + let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64; + let reach = r * rng.range(0.55, 1.0); + (x + reach * a.cos(), y + reach * a.sin()) + }) + .collect(); + polygon(&points) +} + +/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle. +fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData { + let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos(); + let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0); + polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)]) +} + +/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from +/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling +/// draws the part outside the tile on the next tile, so the tiles join without a seam. +fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) { + let mut rng = SplitMix64(seed); + let marks = (0..count) + .map(|_| { + let (x, y) = (rng.range(0.0, size), rng.range(0.0, size)); + mark(&mut rng, x, y) + }) + .collect(); + add(d, name, size, size, TileType::Grid, marks); +} + +/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on +/// a clear background, each drawn from its own fixed seed. +fn grit_textures(d: &mut Document) { + grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35))); + grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| { + let r = rng.range(0.45, 1.1); + fleck(rng, x, y, r) + }); + grit(d, "Sand", 24.0, 3, 280, |rng, x, y| { + let r = rng.range(0.2, 0.45); + fleck(rng, x, y, r) + }); + grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9))); + // Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center. + let mut rng = SplitMix64(5); + let (size, cells) = (24.0, 12); + let step = size / f64::from(cells); + let dots = (0..cells) + .flat_map(|row| (0..cells).map(move |col| (col, row))) + .map(|(col, row)| { + let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step; + let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step; + dot(x, y, rng.range(0.3, 0.45)) + }) + .collect(); + add(d, "Stipple", size, size, TileType::Grid, dots); + grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| { + let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35)); + scratch(rng, x, y, len, width) + }); + grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| { + let r = rng.range(0.35, 0.8); + fleck(rng, x, y, r) + }); + grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| { + let r = rng.range(0.2, 0.5); + fleck(rng, x, y, r) + }); +} + #[cfg(test)] mod tests { use super::*; @@ -168,4 +270,60 @@ mod tests { assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}"); } } + + fn grit_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + grit_textures(&mut d); + d + } + + #[test] + fn grit_textures_draw_the_same_marks_every_time() { + let (a, b) = (grit_doc(), grit_doc()); + assert_eq!(a.patterns.len(), 8); + assert_eq!(a.patterns, b.patterns); + } + + /// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile + /// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile + /// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw + /// twice in one place. + #[test] + fn grit_tiles_join_without_a_seam() { + let mut crossing = 0; + for p in &grit_doc().patterns { + let (w, h) = (p.width(), p.height()); + assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len()); + let bounds: Vec<Rect> = p + .art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)), + _ => panic!("{}: a mark that is not a path", p.name), + }) + .collect(); + let near = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + let band = p.tile.inflate(w / 4.0, h / 4.0); + for b in &bounds { + assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name); + // The tile past each edge the mark crosses draws this tile's art. + let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0))); + for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] { + let next = p.tile + kurbo::Vec2::new(x * w, y * h); + assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name); + } + crossing += usize::from(dx != 0.0 || dy != 0.0); + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + assert!(crossing > 0, "no mark reaches past an edge"); + } } diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 3ecb5e68e1..8271cfa564 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -765,6 +765,39 @@ fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() { assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint)); } +/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch +/// once, as one undo step that also takes both away, and both stay after a save and reopen. +#[test] +fn a_grit_texture_adds_once_undoes_and_saves() { + let mut s = session(); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]})); + assert_eq!(r["added"], json!(["Stipple"])); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone(); + assert!(def.art.len() >= 144, "{} marks", def.art.len()); + assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple")); + let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none()); + run(&mut s, "edit.redo", json!({})); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Stipple"), Some(&def)); + assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 24f16e1794..8c3f0ad02b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5810,6 +5810,7 @@ theme Appearance Външен вид Ruby Руби Geometric Guides Геометрични водачи Geometric Patterns Геометрични мостри + Grit Textures Зърнести текстури Ease In Плавно начало Ease Out Плавен край Ease In Out Плавно начало и край diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 83290bdbd9..bb8ba457ed 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5827,6 +5827,7 @@ mark-style Japanese Japanski Ruby Ruby Geometric Guides Geometrijske vodilice Geometric Patterns Geometrijski uzorci + Grit Textures Zrnaste teksture Ease In Ublaži ulaz Ease Out Ublaži izlaz Ease In Out Ublaži ulaz i izlaz diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 717c2b59d9..01d16f3d51 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1872,6 +1872,7 @@ digits Malayalam Malaiàlam Gridline every Línia de quadrícula cada Gridline every: Línia de quadrícula cada: Grids In Back Quadrícules al darrere + Grit Textures Textures granulades Group Agrupa Group Selection Selecció de grup Group Selection Tool Eina de selecció de grup diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 216579443f..1554110e28 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5980,6 +5980,7 @@ mark-style Japanese Japonské Ruby Ruby Geometric Guides Geometrická vodítka Geometric Patterns Geometrické vzorky + Grit Textures Zrnité textury Ease In Pozvolný začátek Ease Out Pozvolný konec Ease In Out Pozvolný začátek a konec diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2339ca27ed..07a9d73cd1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5825,6 +5825,7 @@ swatch Bright Violet Kraftig violet Ruby Ruby Geometric Guides Geometriske hjælpelinjer Geometric Patterns Geometriske mønstre + Grit Textures Kornede teksturer Ease In Langsom start Ease Out Langsom slutning Ease In Out Langsom start og slutning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e5ad28a0c2..4c669c24e4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5561,6 +5561,7 @@ effect Soft Weich Ruby Ruby Geometric Guides Geometrische Hilfslinien Geometric Patterns Geometrische Muster + Grit Textures Körnige Texturen Ease In Langsam starten Ease Out Langsam enden Ease In Out Langsam starten und enden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c87ecb5713..7642033e06 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5931,6 +5931,7 @@ effect Points Σημεία Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί Geometric Patterns Γεωμετρικά μοτίβα + Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή Ease Out Ομαλό τέλος Ease In Out Ομαλή αρχή και τέλος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db8da4cc35..5e1fdfb1c7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5922,6 +5922,7 @@ mark-style Japanese Japonesas Ruby Ruby Geometric Guides Guías geométricas Geometric Patterns Motivos geométricos + Grit Textures Texturas granuladas Ease In Entrada suave Ease Out Salida suave Ease In Out Entrada y salida suaves diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c3d7fcfb91..4f9413ff4f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6010,6 +6010,7 @@ swatch Bright Violet Kirkas violetti Ruby Ruby Geometric Guides Geometriset apuviivat Geometric Patterns Geometriset kuviot + Grit Textures Rakeiset tekstuurit Ease In Hidas alku Ease Out Hidas loppu Ease In Out Hidas alku ja loppu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e557d273c9..c81852fcdc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5975,6 +5975,7 @@ theme Appearance Apparence Ruby Ruby Geometric Guides Repères géométriques Geometric Patterns Motifs géométriques + Grit Textures Textures granuleuses Ease In Accélération progressive Ease Out Décélération progressive Ease In Out Accélération et décélération diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 129dfeea3c..3a40dc706b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2064,6 +2064,7 @@ antiAlias Sharp शार्प Gridline every ग्रिडलाइन हर Gridline every: ग्रिडलाइन हर: Grids In Back ग्रिड में वापस + Grit Textures दानेदार टेक्सचर Group समूह Group Overlapping Objects समूह ओवरलैपिंग ऑब्जेक्ट Group Selection समूह सिलेक्शन diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 78c096b113..7de4f671c9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5963,6 +5963,7 @@ imageTrace Outline Körvonal Ruby Ruby Geometric Guides Geometriai segédvonalak Geometric Patterns Geometriai mintázatok + Grit Textures Szemcsés textúrák Ease In Lassú kezdés Ease Out Lassú befejezés Ease In Out Lassú kezdés és befejezés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 002740e114..91ad56ef22 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5825,6 +5825,7 @@ swatch Bright Violet Violet cerah Ruby Ruby Geometric Guides Panduan Geometris Geometric Patterns Pola Geometris + Grit Textures Tekstur Butiran Ease In Awal Lambat Ease Out Akhir Lambat Ease In Out Awal dan Akhir Lambat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7f230b10f4..2710544ddb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5595,6 +5595,7 @@ lighting Light Luce Ruby Ruby Geometric Guides Guide geometriche Geometric Patterns Motivi geometrici + Grit Textures Texture granulose Ease In Inizio graduale Ease Out Fine graduale Ease In Out Inizio e fine graduali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 88c9013b2e..ddced9ea96 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5645,6 +5645,7 @@ lighting Light ライト Ruby ルビ Geometric Guides 幾何ガイド Geometric Patterns 幾何パターン + Grit Textures 粒状テクスチャ Ease In イーズイン Ease Out イーズアウト Ease In Out イーズインアウト diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4b7dfdcac7..cd3c1ece1a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5920,6 +5920,7 @@ imageTrace Outline კონტური Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები Geometric Patterns გეომეტრიული პატერნები + Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი Ease Out ნელი დასასრული Ease In Out ნელი დასაწყისი და დასასრული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ac3f3d0cf6..c4c32f6a67 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5556,6 +5556,7 @@ lighting Light 광원 Ruby 루비 Geometric Guides 기하학적 안내선 Geometric Patterns 기하학적 패턴 + Grit Textures 입자 텍스처 Ease In 서서히 시작 Ease Out 서서히 종료 Ease In Out 서서히 시작 및 종료 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0622156953..ab2017cd55 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5876,6 +5876,7 @@ swatch Bright Violet Felviolet Ruby Ruby Geometric Guides Geometrische hulplijnen Geometric Patterns Geometrische patronen + Grit Textures Korrelige texturen Ease In Langzaam beginnen Ease Out Langzaam eindigen Ease In Out Langzaam beginnen en eindigen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 11dd0dc99a..6d5d9f8eaf 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5834,6 +5834,7 @@ mark-style Japanese Japońskie Ruby Ruby Geometric Guides Geometryczne linie pomocnicze Geometric Patterns Wzorki geometryczne + Grit Textures Tekstury ziarniste Ease In Łagodny początek Ease Out Łagodny koniec Ease In Out Łagodny początek i koniec diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 62b2fa772a..ffa02858e2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5895,6 +5895,7 @@ mark-style Japanese Japonesas Ruby Ruby Geometric Guides Guias geométricas Geometric Patterns Padrões geométricos + Grit Textures Texturas granuladas Ease In Entrada suave Ease Out Saída suave Ease In Out Entrada e saída suaves diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 87c359b83b..ea39a33867 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5589,6 +5589,7 @@ lighting Light Свет Ruby Руби Geometric Guides Геометрические направляющие Geometric Patterns Геометрические узоры + Grit Textures Зернистые текстуры Ease In Плавное начало Ease Out Плавное окончание Ease In Out Плавное начало и окончание diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 92de527ff1..1095a88d12 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6003,6 +6003,7 @@ font-script Japanese Japanska Ruby Ruby Geometric Guides Geometriska stödlinjer Geometric Patterns Geometriska mönster + Grit Textures Korniga texturer Ease In Långsam start Ease Out Långsamt slut Ease In Out Långsam start och slut diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e2ec9c6851..4569d6777c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5975,6 +5975,7 @@ axis Y Axis Y ekseni Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar Geometric Patterns Geometrik Desenler + Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 235a4d389e..a92b04b758 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5575,6 +5575,7 @@ lighting Light Світло Ruby Рубі Geometric Guides Геометричні напрямні Geometric Patterns Геометричні візерунки + Grit Textures Зернисті текстури Ease In Плавний початок Ease Out Плавне завершення Ease In Out Плавний початок і завершення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87d2c22625..2e5deb16b2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5576,6 +5576,7 @@ theme Appearance Giao diện Ruby Ruby Geometric Guides Đường dẫn hình học Geometric Patterns Họa tiết hình học + Grit Textures Kết cấu hạt Ease In Chậm lúc đầu Ease Out Chậm lúc cuối Ease In Out Chậm lúc đầu và cuối diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eea8526b99..d3e0952825 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5817,6 +5817,7 @@ lighting Light 光源 Ruby 注音 Geometric Guides 几何参考线 Geometric Patterns 几何图案 + Grit Textures 颗粒纹理 Ease In 缓入 Ease Out 缓出 Ease In Out 缓入缓出 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 60cc2d1d43..0a9112879a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5613,6 +5613,7 @@ lighting Light 光源 Ruby 注音 Geometric Guides 幾何參考線 Geometric Patterns 幾何圖樣 + Grit Textures 顆粒紋理 Ease In 緩入 Ease Out 緩出 Ease In Out 緩入緩出 diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 31976746e3..f28e7e5540 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,8 +155,9 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "geometric-patterns", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::<SwatchLibraries>(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); + assert_eq!(neighbour::<SwatchLibraries>(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::<SwatchLibraries>(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..8f726e5d31 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3290,8 +3290,9 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern library Geometric Patterns (`geometric-patterns`, category -`patterns`) holds pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, +hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..86cd65e34a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1274,7 +1274,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From aeeb90e60c4badc15920bfdaa1928c9a981b978d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:41:04 -0800 Subject: [PATCH 066/126] Save and Export: the hover's note list states its limits The hover lists up to 30 notes, fewer when the window is small, and says how many were left out. The docs and command description said it lists them all. --- crates/ui-egui/src/io/export.rs | 2 +- crates/ui-egui/src/io/save.rs | 3 ++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/mcp.md | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 246511669e..7e5bc01380 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -244,7 +244,7 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), /// back as base64. With `openLocation`, the file manager shows the first file written. When an /// export into a folder or a download has warnings, the status bar shows them as [`report_saved`] /// shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …", and hovering the -/// message lists them all. +/// message lists up to 30 of them, fewer when the window is small, with how many were left out. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> { export_files(app, "document.exportForScreens", params) } diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 708557fc4a..42b76ce2d8 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -395,7 +395,8 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { } /// "Saved <path>", with the first of the result's warnings and how many there are; hovering the -/// message lists them all ([`show_with_notes`]). +/// message lists up to 30 of them, fewer when the window is small, with how many were left out +/// ([`show_with_notes`]). pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { show_with_notes(app, saved_status(path, r), notes_of(r)); } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 6c842814e1..f958afefdd 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1487,7 +1487,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists them all)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}", ), ( "file.print", diff --git a/docs/mcp.md b/docs/mcp.md index 4905ee30f7..a9b3b0be1f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6386,7 +6386,7 @@ writes the `.css` file with the PNGs of rasterized art next to it (no path: `dat `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the -message lists them all. +message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. ```json {"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}} From 386e405800390afd6d8b675fdcd103ee078e912c Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Thu, 12 Nov 2026 10:47:06 +0500 Subject: [PATCH 067/126] Commands: draw_shape and object.reflect say which way they turn; test the notes the key walk misses (#6340) draw_shape's rotation also turns rectangles and ellipses; object.reflect's numeric axis is counter-clockwise from horizontal. A test covers that axis and the Scatter brush's rotation range, which every_turn_param_says_which_way_it_turns can't reach. --- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/tests/turn_direction.rs | 9 +++++++++ crates/mcp/src/tools/definitions.rs | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index eb019a88e9..5c66ec8442 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs index f12f0d1962..a8961f3c46 100644 --- a/crates/engine/src/tests/turn_direction.rs +++ b/crates/engine/src/tests/turn_direction.rs @@ -114,3 +114,12 @@ fn keys_named(doc: &str) -> Vec<String> { } found.into_iter().map(|(k, _)| k).collect() } + +/// Turn notes the key walk above doesn't reach: a numeric reflection axis and the Scatter brush's +/// `rotation [a, b]` range, whose head isn't a plain key. +#[test] +fn turns_outside_plain_keys_say_which_way() { + let doc = |id: &str| command_specs().iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default(); + assert!(doc("object.reflect").contains("|deg (the mirror line, counter-clockwise from horizontal"), "object.reflect's numeric axis"); + assert!(doc("brush.options").contains("rotation [a, b] −180..180° (counter-clockwise)"), "the Scatter brush's rotation"); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 17c7ecc912..781a2bf14d 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -259,7 +259,7 @@ pub fn tool_definitions() -> Vec<Value> { "radius1": num("Star outer radius"), "radius2": num("Star inner radius"), "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees clockwise (polygon/star; 0: a corner or point straight up)"), + "rotation": num("Rotation in degrees clockwise about the centre (rectangle/ellipse: 0 upright; polygon/star: 0 a corner or point straight up)"), "x1": num("Line start x"), "y1": num("Line start y"), "x2": num("Line end x"), From 283e1267f6823e6b8370432e53b1338e89cd9fab Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:50:54 -0800 Subject: [PATCH 068/126] Script checker: names with escaped characters are read as names - A name may hold \u{...} escapes with any number of hexadecimal digits up to U+10FFFF. An empty, unclosed, out-of-range or surrogate escape is not part of a name. Each \u{...} escape read in a name is reported as syntax newer than ES3, with the escape as written. - # followed by an escaped name start is one private name, also in the code of an XML literal, and its escapes are read in the report (this.#app is #app). - A # and a name at the start of a line is a private name, not a directive, when the name starts with an escape, or when the line is inside braces and the name is not a directive's. A private name whose decoded name would make the line a directive is reported as written. - The identifier test against vectorcraft-js also compares braced escapes. Docs: docs/mcp.md, docs/target-app-parity.md. --- .../tests/it/js_lexer_vs_scriptcheck.rs | 12 ++ crates/scriptcheck/src/analyze.rs | 104 +++++++++---- crates/scriptcheck/src/includes.rs | 4 +- crates/scriptcheck/src/lex.rs | 89 +++++++++-- crates/scriptcheck/src/lib.rs | 33 +++-- crates/scriptcheck/src/tests.rs | 138 +++++++++++++++++- docs/mcp.md | 11 +- docs/target-app-parity.md | 2 +- 8 files changed, 331 insertions(+), 62 deletions(-) diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index ba029dd3b1..db8696a10f 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -115,15 +115,27 @@ fn both_lexers_take_the_same_characters_into_names() { assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}")); for &c in NAME_CHARS { let escape = format!("\\u{:04X}", u32::from(c)); + let braced = format!("\\u{{{:X}}}", u32::from(c)); for (src, name) in [ (format!("a{c}b"), format!("a{c}b")), (format!("{c}b"), format!("{c}b")), (format!("a{escape}b"), format!("a{c}b")), (format!("{escape}b"), format!("{c}b")), + (format!("a{braced}b"), format!("a{c}b")), + (format!("{braced}b"), format!("{c}b")), ] { assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); } } + // Braced escapes past U+FFFF and with leading zeros are read by both. + for (src, name) in [("\\u{1D49C}b", "\u{1D49C}b"), ("a\\u{0000000062}", "ab")] { + assert!(checker_member(src, name) && one_identifier(src, name), "{src:?}"); + } + // Out of range, a surrogate and an empty one: the parser reads no identifier, and the checker's + // name ends before the escape. + for src in ["a\\u{110000}", "a\\u{D800}", "a\\u{}"] { + assert!(checker_member(src, "a") && !one_identifier(src, "a"), "{src:?}"); + } } #[test] diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 9a637274df..b8f887ff37 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -88,12 +88,25 @@ pub(crate) struct Scan { /// Scan `src` (already through [`unescaped`]): the script itself (`file` none) or a file it includes /// (`file`: its path inside the script's folder), with the names it declares (`declared`, from /// [`declarations`] on the same `src`), the `#includepath` folders `paths` in force at its start and -/// its variables holding what `known` says. -pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>, paths: Rc<[String]>, known: &Known, acc: &mut Acc) -> Scan { +/// its variables holding what `known` says. Each of the `\u{…}` escapes `braced` that [`unescaped`] +/// read is syntax later than ECMAScript 3, reported when the scan reaches the escape's line. +pub(crate) fn scan<'s>( + src: &'s str, + declared: &Declared<'s>, + braced: &[Braced], + file: Option<&str>, + paths: Rc<[String]>, + known: &Known, + acc: &mut Acc, +) -> Scan { let mut lexer = Lexer::new(src, acc.limits.nesting); let mut a = Analyzer::new(acc, file, paths, known, declared); let mut breaks = LineBreaks::default(); + let mut braced = braced.iter().peekable(); for t in lexer.by_ref() { + while let Some((line, escape)) = braced.next_if(|(line, _)| *line <= t.line) { + a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape)); + } if let Some(code) = t.problem { a.problem(code, t.line); } @@ -103,6 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> Read::Token(sig) => a.token(sig), } } + for (line, escape) in braced { + a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape)); + } if let Some(line) = lexer.unclosed_xml { a.problem(report::UNTERMINATED_XML, line); } @@ -115,29 +131,61 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> Scan { includes, module, lines: lexer.lines(), known } } -/// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a -/// name may not hold where the escape stands stays as written. Line breaks don't change, so every -/// line keeps its number. -pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { - match unescape_names(&src, acc) { - Some(out) => Cow::Owned(out), - None => src, +/// A `\u{…}` escape in a name, which ECMAScript 3 does not have: its line and its text. +pub(crate) type Braced = (usize, String); + +/// `src` with the `\uXXXX` and `\u{…}` escapes in its names and private names written as the +/// characters they stand for (ECMA-262, Names and Keywords), and `src` itself when no name has one, +/// with the `\u{…}` escapes read, each listed once per line in a row. An escape for a character a +/// name may not hold where the escape stands stays as written, and so does a private name that +/// starts a line where the name it spells would start a preprocessor line. Line breaks don't +/// change, so every line keeps its number. +pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> (Cow<'a, str>, Vec<Braced>) { + let mut braced = vec![]; + match unescape_names(&src, acc, &mut braced) { + Some(out) => (Cow::Owned(out), braced), + None => (src, braced), } } -fn unescape_names(src: &str, acc: &mut Acc) -> Option<String> { +fn unescape_names(src: &str, acc: &mut Acc, braced: &mut Vec<Braced>) -> Option<String> { if !src.contains("\\u") { return None; } let mut out = String::with_capacity(src.len()); let mut changed = false; let mut lexer = Lexer::new(src, acc.limits.nesting); - for t in lexer.by_ref() { - if t.kind == T::Ident && t.text.contains("\\u") { - let name = unescape_name(t.text); - changed |= name != t.text; - out.push_str(&name); + // One more than the report keeps, so that the report is marked truncated. + let room = acc.limits.entries.saturating_add(1); + // Only blanks since the last line break (or the start). + let mut line_start = true; + let mut found = vec![]; + while let Some(t) = lexer.next() { + // A private name is `#` and a name. + let (mark, name) = match t.kind { + T::Ident => ("", t.text), + T::Other => t.text.strip_prefix('#').map_or(("", ""), |name| ("#", name)), + _ => ("", ""), + }; + let starts_line = line_start; + line_start = t.kind == T::Newline || (line_start && t.kind == T::Space); + if name.contains("\\u") { + found.clear(); + let written = unescape_name(name, t.line, &mut found, room); + for escape in found.drain(..) { + if braced.last() != Some(&escape) && braced.len() < room { + braced.push(escape); + } + } + // A private name that starts a line stays as written where the name it spells would + // start a preprocessor line. + if mark == "#" && starts_line && !lexer.private_line(&written) { + out.push_str(t.text); + continue; + } + changed |= written != name; + out.push_str(mark); + out.push_str(&written); } else { out.push_str(t.text); } @@ -146,25 +194,31 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option<String> { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written -/// as that character: a character a name may start with first, and one a name may hold after that. -fn unescape_name(text: &str) -> String { +/// The name `text`, on `line`, with each escape for a character a name may hold where it stands +/// written as that character: a character a name may start with first, and one a name may hold +/// after that. Each `\u{…}` escape read goes into `braced`, unless the last one there is the same +/// escape on the same line or `braced` holds `room` of them. +fn unescape_name(text: &str, line: usize, braced: &mut Vec<Braced>, room: usize) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); - let after = rest.get(i + 2..).unwrap_or_default(); + let at = rest.get(i..).unwrap_or_default(); let first = out.is_empty(); - let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) - .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); + let c = crate::lex::escape(at).filter(|&(c, _)| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { - Some(c) => { + Some((c, len)) => { out.push(c); - rest = after.get(4..).unwrap_or_default(); + let source = at.get(..len).unwrap_or_default(); + let repeated = braced.last().is_some_and(|(l, s)| *l == line && s == source); + if source.starts_with("\\u{") && !repeated && braced.len() < room { + braced.push((line, source.to_string())); + } + rest = at.get(len..).unwrap_or_default(); } None => { out.push_str("\\u"); - rest = after; + rest = at.get(2..).unwrap_or_default(); } } } diff --git a/crates/scriptcheck/src/includes.rs b/crates/scriptcheck/src/includes.rs index f6e9c82b09..b4a7be7219 100644 --- a/crates/scriptcheck/src/includes.rs +++ b/crates/scriptcheck/src/includes.rs @@ -141,9 +141,9 @@ pub(crate) fn follow( read.insert(path.clone(), (status, Some(file))); let (text, _) = crate::decode(&b); let mut tallies = Acc::new(limits); - let text = analyze::unescaped(text, &mut tallies); + let (text, _) = analyze::unescaped(text, &mut tallies); let declared = analyze::declarations(&text, &mut tallies); - let scan = analyze::scan(&text, &declared, Some(&path), Rc::clone(&inc.paths), known, &mut tallies); + let scan = analyze::scan(&text, &declared, &[], Some(&path), Rc::clone(&inc.paths), known, &mut tallies); acc.steps = acc.steps.saturating_add(tallies.steps); known.add(scan.known, limits.entries); checked.push(ReadFile { path: path.clone(), bytes: b, paths: Rc::clone(&inc.paths) }); diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 715a0582b0..02d8c2de03 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -41,7 +41,9 @@ pub(crate) enum T { /// end or the next `{`. XmlMore, Punct, - /// A character no token starts with, or `#` and a name away from the start of a line. + /// A character no token starts with, or a private name: `#` and a name away from the start of a + /// line, or at its start where the name starts with an escape, or inside braces where the name + /// is not a directive's. Other, } @@ -166,14 +168,33 @@ pub(crate) fn ident_part(c: char) -> bool { } } -/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start -/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. -pub(crate) fn escaped(s: &str) -> Option<char> { - s.strip_prefix("\\u")? - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) +/// The character the escape at the start of `s` stands for and the escape's length in bytes: `\u` +/// and four hexadecimal digits, or `\u{…}` with one or more hexadecimal digits of a value up to +/// U+10FFFF, leading zeros allowed (ECMA-262, UnicodeEscapeSequence and CodePoint). `None` when `s` +/// starts with neither, or when the escape gives a lone surrogate. +pub(crate) fn escape(s: &str) -> Option<(char, usize)> { + let rest = s.strip_prefix("\\u")?; + let Some(braced) = rest.strip_prefix('{') else { + let c = rest + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32)?; + return Some((c, 6)); + }; + // The value stops growing past U+10FFFF, so any number of digits is read once and never + // overflows. + let mut value: u32 = 0; + for (i, b) in braced.bytes().enumerate() { + match b { + b'}' if i > 0 => return char::from_u32(value).map(|c| (c, i + 4)), + _ => { + let digit = char::from(b).to_digit(16)?; + value = (value * 16 + digit).min(0x11_0000); + } + } + } + None } /// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is @@ -394,18 +415,56 @@ impl<'s> Lexer<'s> { } /// The rest of a name (its first character included): the characters [`ident_part`] allows, - /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four - /// hexadecimal digits, ends the name. + /// written out or as `\uXXXX` or `\u{…}` escapes ([`escape`]). An escape for any other + /// character, or a malformed one, ends the name. Each character of an escape is a step. fn name(&mut self) { loop { match self.peek() { - Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), + Some('\\') => match escape(self.rest()) { + Some((c, len)) if ident_part(c) => { + self.pos += len; + self.steps += len; + } + _ => break, + }, Some(c) if ident_part(c) => self.bump(c), _ => break, } } } + /// A name starts right after the `#` here: its first character written out, or an escape for a + /// character a name may start with. + fn private_name_follows(&self) -> bool { + let after = self.rest().get(1..).unwrap_or_default(); + match after.chars().next() { + Some('\\') => escape(after).is_some_and(|(c, _)| ident_start(c)), + Some(c) => ident_start(c), + None => false, + } + } + + /// The `#` at the start of this line starts a private name, not a preprocessor line: the name + /// starts with an escape, which no directive's name holds, or [`Lexer::private_line`] holds for + /// it. + fn private_at_line_start(&self) -> bool { + if !self.private_name_follows() { + return false; + } + let after = self.rest().get(1..).unwrap_or_default(); + after.starts_with('\\') || self.private_line(after) + } + + /// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line: + /// the line is inside braces or in the code of an XML literal, and the name `rest` starts with + /// is not a directive's. + pub fn private_line(&self, rest: &str) -> bool { + let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty(); + let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()); + let name = rest.get(..end).unwrap_or_default(); + in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name)) + } + fn digits(&mut self, ok: fn(char) -> bool) { while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') { self.bump(c); @@ -871,10 +930,10 @@ impl<'s> Iterator for Lexer<'s> { } else if self.starts("/*") { problem = self.block_comment(); T::Comment - } else if c == '#' && self.line_start { + } else if c == '#' && self.line_start && !self.private_at_line_start() { self.until_line_break(); T::Directive - } else if c == '#' && self.peek_at(1).is_some_and(ident_start) { + } else if c == '#' && self.private_name_follows() { self.bump('#'); self.name(); T::Other @@ -906,7 +965,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { + } else if ident_start(c) || (c == '\\' && escape(self.rest()).is_some_and(|(c, _)| ident_start(c))) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/lib.rs b/crates/scriptcheck/src/lib.rs index 0c62419f1c..2bd8c60fba 100644 --- a/crates/scriptcheck/src/lib.rs +++ b/crates/scriptcheck/src/lib.rs @@ -166,14 +166,14 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: if invalid { acc.problem(report::NOT_UTF8, None, None, None); } - let text = analyze::unescaped(text, &mut acc); + let (text, braced) = analyze::unescaped(text, &mut acc); let declared = analyze::declarations(&text, &mut acc); let none = analyze::Known::default(); // With a loader, this first scan finds the includes and what the script's variables hold; its // tallies are left out. let mut tallies = report::Acc::new(limits); let pre = acc.clone(); - let scan = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc }); + let scan = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc }); acc.steps = acc.steps.saturating_add(tallies.steps); if scan.module { // A module's report holds its kind and its lines, and none of what the scan found. @@ -183,7 +183,7 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: return acc.finish(Kind::Module, scan.lines); } let Some(loader) = loader else { - let mut acc = settle(acc, pre, &text, &declared, scan.known); + let mut acc = settle(acc, pre, &text, &declared, &braced, scan.known); includes::not_read(&mut acc, scan.includes); return acc.finish(Kind::Script, scan.lines); }; @@ -191,17 +191,23 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known); // Each file's text and the names it declares are worked out once for all the scans below. let decoded: Vec<_> = read.iter().map(|file| decode(&file.bytes)).collect(); - let texts: Vec<_> = decoded.into_iter().map(|(text, invalid)| (analyze::unescaped(text, &mut acc), invalid)).collect(); - let declarations: Vec<_> = texts.iter().map(|(text, _)| analyze::declarations(text, &mut acc)).collect(); + let texts: Vec<_> = decoded + .into_iter() + .map(|(text, invalid)| { + let (text, braced) = analyze::unescaped(text, &mut acc); + (text, braced, invalid) + }) + .collect(); + let declarations: Vec<_> = texts.iter().map(|(text, ..)| analyze::declarations(text, &mut acc)).collect(); // A variable may get what it holds from another file's variable: the files are scanned again, // each with what is known so far, while that grows, at most three more times. for _ in 0..3 { let before = known.len(); let mut tallies = report::Acc::new(limits); - let more = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut tallies).known; + let more = analyze::scan(&text, &declared, &[], None, std::rc::Rc::from([]), &known, &mut tallies).known; known.add(more, limits.entries); - for ((file, (text, _)), declared) in read.iter().zip(&texts).zip(&declarations) { - let more = analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known; + for ((file, (text, ..)), declared) in read.iter().zip(&texts).zip(&declarations) { + let more = analyze::scan(text, declared, &[], Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known; known.add(more, limits.entries); } acc.steps = acc.steps.saturating_add(tallies.steps); @@ -210,12 +216,12 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: } } // The scans for the report: every file starts with what any file's variables hold. - let lines = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut acc).lines; - for ((file, (text, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) { + let lines = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &known, &mut acc).lines; + for ((file, (text, braced, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) { if *invalid { acc.problem(report::NOT_UTF8, None, Some(&file.path), None); } - analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc); + analyze::scan(text, declared, braced, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc); } acc.finish(Kind::Script, lines) } @@ -230,6 +236,7 @@ fn settle<'l>( pre: report::Acc<'l>, text: &str, declared: &analyze::Declared<'_>, + braced: &[analyze::Braced], mut known: analyze::Known, ) -> report::Acc<'l> { if known.len() == 0 { @@ -240,7 +247,7 @@ fn settle<'l>( for _ in 0..3 { let before = known.len(); let mut acc = pre.clone(); - let more = analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc).known; + let more = analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc).known; known.add(more, entries); if known.len() == before { acc.steps = acc.steps.saturating_add(spent); @@ -249,7 +256,7 @@ fn settle<'l>( spent = spent.saturating_add(acc.steps.saturating_sub(pre.steps)); } let mut acc = pre; - analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc); + analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc); acc.steps = acc.steps.saturating_add(spent); acc } diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 31cbef823a..19cee61603 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -271,7 +271,7 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { /// `src` with the escapes in its names written as the characters they stand for. fn unescaped(src: &str) -> String { let limits = Limits::default(); - crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).0.into_owned() } #[test] @@ -364,6 +364,140 @@ fn malformed_escapes_in_bulk_are_read_once_through() { assert_eq!(r.lines, 3); assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); + // Braced escapes: long runs of digits, closed and not, and ones that end at once. + let zeros = "0".repeat(100_000); + let src = format!( + "a\\u{{{zeros}62}};\n\\u{{{}\n{}\n{}\nalert(1);\n", + "f".repeat(100_000), + "\\u{".repeat(20_000), + "\\u{0000000061}\\u{110000}\\u{}\\u{D800}#\\u{61}".repeat(10_000) + ); + let r = report(&src); + assert_eq!(r.lines, 5); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[5][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + +#[test] +fn braced_escapes_in_names_are_read_by_their_value() { + for (src, want) in [ + ("\\u{61}pp", "app"), + ("a\\u{62}c", "abc"), + ("\\u{0000000061}", "a"), + ("\\u{2118}\\u{203F}", "\u{2118}\u{203F}"), + ("a\\u{1D7CE}", "a\u{1D7CE}"), + ("\\u{1D49C}", "\u{1D49C}"), + // Not at the start of a name, in no name, out of range, empty, a surrogate and not closed. + ("\\u{30}a", "\\u{30}a"), + ("a\\u{1F600}", "a\\u{1F600}"), + ("a\\u{110000}", "a\\u{110000}"), + ("a\\u{FFFFFFFFFFFFFFFFFFFF}b", "a\\u{FFFFFFFFFFFFFFFFFFFF}b"), + ("a\\u{}b", "a\\u{}b"), + ("a\\u{D800}", "a\\u{D800}"), + ("a\\u{61", "a\\u{61"), + ("a\\u{6 1}", "a\\u{6 1}"), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + assert_eq!(tokens("\\u{61}pp.x"), [(T::Ident, "\\u{61}pp"), (T::Punct, "."), (T::Ident, "x")]); + assert_eq!(tokens("a\\u{0000000062}"), [(T::Ident, "a\\u{0000000062}")]); + assert_eq!(tokens("a\\u{1F600}").get(..2), Some(&[(T::Ident, "a"), (T::Other, "\\")][..])); +} + +#[test] +fn braced_name_escape_reports_later_syntax_and_member() { + let r = report("\\u{61}pp.activeDocument.acmeName;\nvar a\\u{62}c = 1, \\u{0000000061}pp2 = 2;\n\\u{61}pp.x + \\u{61}pp.y;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.activeDocument.acmeName", "app.x", "app.y"]); + assert_eq!( + problems(&r), + [ + ("newerSyntax", Some(1), Some("\\u{61}")), + ("newerSyntax", Some(2), Some("\\u{62}")), + ("newerSyntax", Some(2), Some("\\u{0000000061}")), + ("newerSyntax", Some(3), Some("\\u{61}")), + ] + ); + // An escape that isn't a name's character, or isn't closed, makes no name and no report of one. + for src in [ + "\\u{1F600}pp.activeDocument;", + "\\u{110000}pp.activeDocument;", + "\\u{}pp.activeDocument;", + "\\u{D800}pp.activeDocument;", + "\\u{61pp.activeDocument;", + ] { + let r = report(src); + assert!(r.members.iter().all(|m| !m.name.starts_with("app")), "{src:?}: {:?}", r.members); + assert!(r.problems.iter().all(|p| p.detail.as_deref().is_none_or(|d| !d.starts_with("\\u{"))), "{src:?}: {:?}", r.problems); + } + // In an included file, the report names the file. + let mut m = mem(&[("lib.jsxinc", "\\u{61}pp.name;\n")]); + let r = check("main.jsx", b"#include 'lib.jsxinc'\n", Some(&mut m), &Limits::default()); + let found: Vec<_> = r.problems.iter().map(|p| (p.code, p.line, p.file.as_deref(), p.detail.as_deref())).collect(); + assert_eq!(found, [("newerSyntax", Some(1), Some("lib.jsxinc"), Some("\\u{61}"))]); +} + +#[test] +fn private_names_with_escaped_starts_are_single_tokens() { + assert_eq!(tokens("a #\\u0061b"), [(T::Ident, "a"), (T::Other, "#\\u0061b")]); + assert_eq!(tokens("this.#\\u0061pp"), [(T::Ident, "this"), (T::Punct, "."), (T::Other, "#\\u0061pp")]); + assert_eq!(tokens("o.#\\u{61}b"), [(T::Ident, "o"), (T::Punct, "."), (T::Other, "#\\u{61}b")]); + // An escape for a character no name starts with leaves `#` on its own. + assert_eq!(tokens("a #\\u0030b").get(..3), Some(&[(T::Ident, "a"), (T::Other, "#"), (T::Other, "\\")][..])); + let r = report("var q = this.#\\u0061pp;\nvar r = o.#\\u{61}b;\n"); + assert!(r.members.is_empty(), "{:?}", r.members); + assert_eq!( + problems(&r), + [("newerSyntax", Some(1), Some("#app")), ("newerSyntax", Some(2), Some("\\u{61}")), ("newerSyntax", Some(2), Some("#ab"))] + ); +} + +#[test] +fn private_name_reports_decoded_construct() { + let r = report("var q = this.#hid\\u0064en;\nvar r = this.#\\u0061\\u0062;\n"); + assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("#hidden")), ("newerSyntax", Some(2), Some("#ab"))]); +} + +#[test] +fn private_name_at_line_start_is_not_a_directive() { + let r = report(concat!( + "class Box {\n", + " #size = 1;\n", + " #\\u0061rea = 2;\n", + " #include 'a.jsxinc'\n", + " #\\u0069nclude = 4;\n", + " #\\u{69}nclude = 5;\n", + "}\n", + "#\\u0062ox = 3;\n", + "#pragma once\n", + "#target acme\n", + )); + let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect(); + assert_eq!(directives, [("include", 4), ("target", 10)]); + // Where the name a private name's escapes spell would start a preprocessor line, the report + // gives the name as written. + assert_eq!( + problems(&r), + [ + ("newerSyntax", Some(1), Some("class")), + ("newerSyntax", Some(2), Some("#size")), + ("newerSyntax", Some(3), Some("#area")), + ("newerSyntax", Some(5), Some("#\\u0069nclude")), + ("newerSyntax", Some(6), Some("\\u{69}")), + ("newerSyntax", Some(6), Some("#\\u{69}nclude")), + ("newerSyntax", Some(8), Some("#\\u0062ox")), + ("unknownDirective", Some(9), Some("#pragma")), + ] + ); +} + +#[test] +fn xml_code_uses_escaped_names() { + let r = report("var x = <a>{\\u{61}pp.activeDocument}</a>;\nvar y = <b>\\u0061pp.z \\u{61}</b>;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.activeDocument"]); + assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("\\u{61}"))]); + assert!(tokens("var x = <a>{this.#\\u0061b}</a>;").contains(&(T::Other, "#\\u0061b"))); } #[test] @@ -1590,7 +1724,7 @@ fn include_lines_share_the_folders_of_the_include_path_before_them() { let limits = Limits::default(); let mut acc = report::Acc::new(&limits); let declared = analyze::declarations(&src, &mut acc); - let scan = analyze::scan(&src, &declared, None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc); + let scan = analyze::scan(&src, &declared, &[], None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc); let (first, rest) = scan.includes.split_first().unwrap(); assert_eq!((first.paths.len(), rest.len()), (64, 999)); assert!(rest.iter().all(|i| std::rc::Rc::ptr_eq(&i.paths, &first.paths)), "one list of folders, not a copy for each line"); diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..5cc3052681 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1627,9 +1627,9 @@ control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, `menuCommand` or `e4x`), each with a count and its lines. - `dom.members` lists the document-model member chains as the script writes them (`app.activeDocument.layers[].name`, with `[]` for an index and `()` for a call); a variable assigned from a chain - is replaced by that chain, and a name written with `\u` escapes is read as the name it spells. `dom.types` lists - the type names the script compares with `.typename` or creates with `new`, apart from the language's, the platform's - and the script's own functions. + is replaced by that chain, and a name written with `\uXXXX` or `\u{…}` escapes is read as the name it spells. + `dom.types` lists the type names the script compares with `.typename` or creates with `new`, apart from the + language's, the platform's and the script's own functions. - `menuCommands.strings` lists the string literals the script passes to `executeMenuCommand` (`app.executeMenuCommand('…')`), each with a count and its lines, and `menuCommands.dynamic` counts the calls whose argument isn't a string literal. @@ -1641,7 +1641,10 @@ control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, template literals and XML literals that aren't closed, brackets that don't match, syntax newer than ES3 and unknown directives) and notes on its text (bytes that aren't valid UTF-8, mixed line endings, and nesting deeper than the check follows: more than 4,096 open brackets, or XML literals and the code in their braces nested more - than 4,096 levels deep). + than 4,096 levels deep). Newer syntax includes a private name (`#name`, given with its escapes read, unless the + name they spell would make the line a directive) and a `\u{…}` escape in a name (given as written). A `#` and a + name at the start of a line is a private name, not a directive, when the name starts with an escape, or when the + line is inside braces and the name is not a directive's. Each entry of `targets`, `directives`, `includes` and `problems` has the `line` it is on (a problem with the whole text has none) and, when it is in an included file, that file's path as `file`. `features`, `menuCommands.strings` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..6596e2a211 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -762,7 +762,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit; a `\u{…}` escape takes any number of hexadecimal digits up to U+10FFFF, and one that is empty, unclosed, out of range or a surrogate is not part of a name. Each `\u{…}` escape read in a name is reported as syntax newer than ES3, which has only the four-digit form (https://ecma-international.org/wp-content/uploads/ECMA-262_3rd_edition_december_1999.pdf, section 7.6). A private name may start with an escape (`this.#\u{61}pp` is the private name `#app`), and a private name at the start of a line inside braces is not read as a directive unless its name is a directive's. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`, `braced_escapes_in_names_are_read_by_their_value`, `braced_name_escape_reports_later_syntax_and_member`, `private_names_with_escaped_starts_are_single_tokens`, `private_name_at_line_start_is_not_a_directive`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 17.0, so U+088F starts an identifier and U+1ACF may follow its first character; characters assigned after Unicode 17.0 are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_17_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_characters_unicode_17_assigned`, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. From ae587e459f5c729d30d05cc447412df2326f09f0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:52:17 -0800 Subject: [PATCH 069/126] Docs: the File formats row lists DXF export's image and hatch options --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9b7d84a030..4903c82f91 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -236,7 +236,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | From c02f95aa31c68d420594cdf8e202f1b3f32dbad4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:50:23 -0800 Subject: [PATCH 070/126] Type: Convert To Point Type turns Dynamic Text off, keeping the sizes Block set Convert To Point Type left Dynamic Text on when it turned Block area type into point type. Point type has no Dynamic Text, but the flag was saved, and converting the type back to area type turned Block on again and fitted the lines to the new frame. The conversion now turns it off; the point type keeps the sizes and leading Block wrote into the characters, and the conversion is still one undo step. --- crates/engine/src/cmd/typecmd/block_tests.rs | 29 ++++++++++++++++++++ crates/engine/src/cmd/typemenu.rs | 5 +++- docs/mcp.md | 4 ++- docs/target-app-parity.md | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 2dda356146..ec458a0c9b 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); } +#[test] +fn convert_to_point_type_turns_block_off_keeping_its_sizes() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::<Vec<_>>(); + let fitted = sizes(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + assert!(!t.dynamic_text, "point type has Dynamic Text off"); + assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set"); + assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText"); + // Back to area type, the frame is not refitted: Block stays off. + s.execute("type.convertToAreaType", &json!({})).unwrap(); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + assert!(!t.dynamic_text, "Block does not come on again"); + assert_eq!(sizes(&t), fitted); + // Undo gives the Block frame back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_eq!(sizes(&t), fitted); +} + #[test] fn bad_params_and_other_type_are_refused() { let mut s = session(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index faaaf2145d..bb0e426c9b 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}", has_selection, to_point ), @@ -410,6 +410,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1, _ => x0, }; + // Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so + // converting back to area type does not turn it on again. + t.dynamic_text = false; t.kind = TextKind::Point; t.xf *= Affine::translate((ox, baseline)); refresh_bounds(t); diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..2668d9af12 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6992,7 +6992,9 @@ the middle of its bounding box's right side, hollow on point type and filled on Type and Convert To Point Type. Either keeps the text and its styles and is one undo step; `ids` picks the objects (default: the selection). A frame of threaded text that `type.convertToPointType` converts leaves its thread with the text it holds, and the story flows through the other frames without that text; a thread left with one frame is no -longer a thread. +longer a thread. Area type with Dynamic Text (Block) on becomes point type with Dynamic Text off, at the sizes Block +set its lines to, as `type.dynamicText {on: false}` leaves them; converting it back to area type does not turn Block +on again. ```json {"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..95eea8716a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1181,7 +1181,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). - Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. From 3dc597f25948f0a0675774f27a775b93c25a69db Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:53:50 -0800 Subject: [PATCH 071/126] Scripts: a call and new String keep their operands rooted while a rope is flattened Boxing the string receiver of a non-strict function flattens it, and reserving the copy can collect. The callee, receiver and arguments are already off the stack at that point, so they stay in the temporary roots until the frame holds them; before, an argument object could be freed and its cell reused by the wrapper. new String(value) roots the converted string the same way, so what the rope holds is still counted while its copy is reserved. --- crates/js/src/interp.rs | 42 ++++++++++++++++++++------------ crates/js/src/string_builtins.rs | 5 ++-- crates/js/src/text_tests.rs | 29 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 9801cc9047..82d0c8f9ac 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2216,26 +2216,19 @@ impl Interp { let arguments = if code.uses_arguments { OBJECT_BYTES.saturating_add(args.len().saturating_add(4).saturating_mul(binding_bytes(10))) } else { 0 }; let need = declaration_bytes(&code).saturating_add(ENV_BYTES).saturating_add(params).saturating_add(arguments); - if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { - let mark = self.temp_roots.len(); + // The operands are off the stack. Reserving the heap collects, and so can boxing a string + // receiver, which flattens it; the operands stay rooted until the frame holds them. + let collects = self.heap.bytes.saturating_add(need) > self.limits.heap_bytes || (!code.strict && matches!(this, Value::String(_))); + let mark = self.temp_roots.len(); + if collects { self.temp_roots.push(Value::Object(callee)); self.temp_roots.push(this.clone()); self.temp_roots.extend(construct.map(Value::Object)); self.temp_roots.extend(args.iter().cloned()); - let reserved = self.reserve_heap(need); - self.temp_roots.truncate(mark); - reserved?; } - let this = if code.strict { - this - } else { - match this { - Value::Undefined | Value::Null => Value::Object(self.global), - Value::Object(_) => this, - prim => Value::Object(self.to_object(&prim)?), - } - }; - let fenv = self.instantiate_function(&code, env, &args, callee)?; + let received = self.receive_call(&code, env, this, &args, callee, need); + self.temp_roots.truncate(mark); + let (this, fenv) = received?; let base = self.stack.len(); self.frames.push(Frame { code, @@ -2253,6 +2246,25 @@ impl Interp { Ok(()) } + /// Reserves the `need` bytes of a call, boxes a primitive receiver for non-strict code and + /// binds the parameters, returning the receiver and the function's environment. + fn receive_call(&mut self, code: &Rc<Code>, env: EnvId, this: Value, args: &[Value], callee: ObjId, need: usize) -> JsResult<(Value, EnvId)> { + if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { + self.reserve_heap(need)?; + } + let this = if code.strict { + this + } else { + match this { + Value::Undefined | Value::Null => Value::Object(self.global), + Value::Object(_) => this, + prim => Value::Object(self.to_object(&prim)?), + } + }; + let fenv = self.instantiate_function(code, env, args, callee)?; + Ok((this, fenv)) + } + /// Declaration binding instantiation for function code (section 10.5). Each parameter and /// declaration takes a unit of fuel. fn instantiate_function(&mut self, code: &Rc<Code>, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult<EnvId> { diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index ba9c79155c..85a27e3261 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -214,8 +214,9 @@ fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) - if !construct { return Ok(Value::String(s)); } - // Flattened now, with its copy counted, so reading a character copies nothing. - flat_charged(interp, &s)?; + // Flattened now, with its copy counted, so reading a character copies nothing. The string + // stays rooted, so what it holds is still counted if reserving the copy collects. + interp.rooted(&[Value::String(s.clone())], |interp| flat_charged(interp, &s))?; let proto = interp.intrinsics.string_proto; Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 03b4fa4339..0c2f051f80 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -354,6 +354,35 @@ fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } +#[test] +fn a_string_wrapper_counts_what_its_rope_holds_while_the_copy_is_made() { + // `mk` returns a rope whose left half is a flat string of about half the heap, held by + // nothing else. Its flattened copy is the other half, so the two don't fit together. + let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ + function mk() { var a = f('a'); a[0]; return a + 'b'; }"; + let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; + let src = format!("{build} new String({{ toString: mk }}); 0"); + let result = Realm::with_options(Options::default(), limits).run(&src); + assert!(matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)), "{result:?}"); +} + +#[test] +fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() { + // Boxing the rope receiver of a non-strict function flattens it, and reserving the copy + // collects the garbage string `g` left. The callee and the argument object are off the + // stack by then, and must survive the collection. + let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \ + function mk() { var a = f('a'); a[0]; return a + 'b'; } 0"; + let mut realm = Realm::with_options(Options::default(), Limits::default()); + assert_eq!(realm.run(build), Ok(JsValue::Number(0.0))); + let interp = realm.interp(); + interp.collect_garbage(); + interp.limits.heap_bytes = interp.heap.bytes + 2 * crate::heap::string_bytes(131_073) + (64 << 10); + interp.reset_gc_threshold(); + let src = "(function () { var g = f('g'); g[0]; })(); (function (o) { return o.x; }).call(mk(), { x: 1 })"; + assert_eq!(realm.run(src), Ok(JsValue::Number(1.0))); +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; From c74821031e6d1e372d50f15daa896efceb31a6c8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:56:02 -0800 Subject: [PATCH 072/126] MCP: hidden library names follow the platform's path rules - Outside Windows a `\` stays part of a hidden library's or sent path's file name; another platform's absolute path is still split on `\`. - A hidden relative path is matched after leading `./` and `../` parts only when they start the path, so `/work/./data/icon.svg` keeps its text. - `ui.inspect` and `ui.docking` replies are hidden once, so a library at the top of the file system shows as `loaded/<file name>` with its separator. --- crates/engine/src/cmd/swatchlib.rs | 19 ++++++---- crates/engine/src/tests/library_roots.rs | 19 ++++++++++ crates/ui-egui/src/control.rs | 39 +++++++++++---------- crates/ui-egui/src/control/tests.rs | 44 ++++++++++++++++++++++-- docs/mcp.md | 3 +- 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 6f007c182d..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -471,6 +471,15 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// The file name a concealed library or path is shown by: the last part of `path` as this platform +/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute +/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one +/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. +pub fn concealed_file_name(path: &str) -> String { + let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); + native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) +} + /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without /// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is /// never readable under a read root. @@ -480,8 +489,8 @@ fn readable(path: &str) -> bool { /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is -/// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute +/// path counts as one the request may not read. The ids are those of the /// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers /// to). When another id already is or shows that id, the next free /// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library @@ -501,11 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin let mut taken = BTreeSet::new(); for ids in [session, others] { for id in ids { - let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| { - // Either separator: a path saved on another platform counts as a path here too. - let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); - (name, path) - }); + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path)); match name { Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 000f0e7532..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -238,6 +238,25 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl` +/// is shown under its whole file name. Another platform's absolute path is still named by the part +/// after its last `\`. +#[cfg(not(windows))] +#[test] +fn a_concealed_library_keeps_a_backslash_in_its_file_name() { + let base = folder("backslash-name"); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); + let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); + let s = Session::new(); + let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); + let _ = std::fs::remove_dir_all(base); + hidden.sort(); + let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; + want.sort(); + assert_eq!(hidden, want); +} + /// A concealed library keeps the id a client was shown when another library with the same file name, /// whose full id sorts before it, is loaded later: the id still finds the first library. #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4ee30c980a..9eb8650235 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); leave_out_unreadable_paths(fields); } - let mut inspected = json!({ + json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), @@ -307,9 +307,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }); - hide_library_ids(&mut inspected, &unreadable_library_ids(app)); - inspected + }) } /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel @@ -435,7 +433,10 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; - let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path); + if name == path { + return; + } let pair = (format!("loaded/{path}"), format!("loaded/{name}")); if !out.contains(&pair) { out.push(pair); @@ -709,17 +710,22 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { } /// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at -/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a -/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path -/// `data/icon.svg`; `./data/icon.svg` does. +/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by +/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and +/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and +/// `../data/icon.svg` do. fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { let absolute = from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - let before = &text[..at]; - !absolute - && !before.ends_with("./") - && !before.ends_with(".\\") - && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) + let mut before = &text[..at]; + while let Some(head) = before.strip_suffix(['/', '\\']) { + let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break }; + before = rest; + if !before.ends_with(['/', '\\']) { + break; + } + } + !absolute && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -1176,12 +1182,7 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => { - let mut docking = crate::panel_docking::inspect(app, ctx); - let ids = unreadable_library_ids(app); - hide_library_ids(&mut docking, &ids); - ok(docking) - } + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), // A request that stops with a panic quoting the status text, after clearing the Color Guide's // limit with `forget`. #[cfg(test)] diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 52b5d9e957..83d8b3ddbb 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -278,14 +278,54 @@ fn another_platforms_absolute_path_is_unreadable() { assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); } -/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a -/// longer path. +/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and +/// stays where it ends a longer path, also one with a `.` or `..` part before it. #[test] fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); hide_under_a_root("relative", &mut reply, &hidden); assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); + let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"}); + hide_under_a_root("relative-dots", &mut reply, &hidden); + assert_eq!( + reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'", + "{reply}" + ); +} + +/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its +/// whole file name. +#[cfg(not(windows))] +#[test] +fn a_sent_path_keeps_a_backslash_in_its_file_name() { + let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); +} + +/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system +/// (`/b.svg`) as `loaded/b.svg`, with its separator. +#[test] +fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let panel = "library:swatches:loaded//b.svg".to_string(); + app.ui.color_guide_limit = "loaded//b.svg".to_string(); + app.ui.open_panel = Some(panel); + let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { + (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); + assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); + assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); } /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded diff --git a/docs/mcp.md b/docs/mcp.md index 00f9dc4bba..e8e0bf79eb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -142,7 +142,8 @@ the suite) keep the agent to the folders you give it: Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts - with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same + with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest From ad05c3b99ddb1fdf7942f1f5caf8fbb24fd9160e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:56:58 -0800 Subject: [PATCH 073/126] Plug-ins: an install that automation asked for is not approved in its dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approval question that a control request, MCP, injected input, or a file dialog one of them opened leads to now keeps OK disabled and refuses every confirm, a person's click included. The dialog says to choose Object › Plug-ins › Install Plug-in…, and ui.installPlugin answers approval: "personMustStartInstall". The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question with its half-second delay. The origin is fixed when the request is made and goes with a native file dialog's deferred answer and the web picker's answers, which now arrive apart from drops and from window.vectorcraft.openFile (always automation). A question about taking another module's place now opens in the approval dialog, and automation's replace: true no longer skips it. A module that needs no approval still installs at once. --- apps/vectorcraft-web/src/main.rs | 2 +- apps/vectorcraft-web/src/web.rs | 41 ++- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/app/files.rs | 27 +- crates/ui-egui/src/app/links.rs | 7 + .../ui-egui/src/dialogs/export_for_screens.rs | 3 +- .../ui-egui/src/dialogs/flattener_presets.rs | 3 +- crates/ui-egui/src/dialogs/pdf_presets.rs | 3 +- .../src/dialogs/perspective_presets.rs | 3 +- crates/ui-egui/src/dialogs/plugin.rs | 49 ++- crates/ui-egui/src/dialogs/print_presets.rs | 3 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/io/open.rs | 31 +- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/libraries.rs | 3 +- crates/ui-egui/src/panels/library_panel.rs | 5 +- crates/ui-egui/src/picks.rs | 58 +++- crates/ui-egui/src/services.rs | 13 +- crates/ui-egui/src/tests/automation.rs | 308 +++++++++++++++++- crates/ui-egui/src/tests/plugins.rs | 30 +- docs/control-protocol.md | 6 +- docs/mcp.md | 5 +- docs/plugins.md | 11 +- docs/target-app-parity.md | 1 + 51 files changed, 569 insertions(+), 100 deletions(-) diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index f7dd201af2..3808c281f9 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -7,7 +7,7 @@ //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); //! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; +//! `Services::picked_inbox`; //! - Save / Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index b4ca434d90..2de7f3b481 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -17,7 +17,7 @@ use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; use vectorcraft_ui_egui::stylus::StylusFeed; -use vectorcraft_ui_egui::{Services, VectorcraftApp}; +use vectorcraft_ui_egui::{PickedInbox, Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; use crate::locks::WebLocks; @@ -25,6 +25,18 @@ use crate::pencil::SharedPointers; pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>; +/// The files waiting to open, by where they came from (`Services::inbox`, `picked_inbox` and +/// `page_inbox`). +#[derive(Clone, Default)] +struct OpenInboxes { + /// Dropped on the page. + dropped: Inbox, + /// The open picker's answers. + picked: PickedInbox, + /// Handed over by the page's script (`window.vectorcraft.openFile`). + page: Inbox, +} + /// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and /// whether Shift was held: where dropped files land. type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>; @@ -48,7 +60,7 @@ const MAX_RESTARTS: u32 = 3; #[derive(Clone)] struct Host { runner: eframe::WebRunner, - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, drag: DragPos, @@ -77,7 +89,7 @@ pub fn start() { crate::fonts::load_startup_fonts().await; let host = Host { runner: eframe::WebRunner::new(), - inbox: Arc::default(), + inbox: OpenInboxes::default(), place_inbox: Arc::default(), script_inbox: Arc::default(), drag: DragPos::default(), @@ -132,13 +144,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi // The old services asked the old context for frames. app.services = services; app.status("The graphics were lost and have been restarted"); - crate::api::connect(None, host.inbox.clone(), ctx.clone()); + crate::api::connect(None, host.inbox.page.clone(), ctx.clone()); app } None => { // `window.vectorcraft`: the page's control channel (crate::api). let (control_tx, control_rx) = std::sync::mpsc::channel(); - crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone()); + crate::api::connect(Some(control_tx), host.inbox.page.clone(), ctx.clone()); if let Err(e) = crate::api::install() { log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e)); } @@ -409,7 +421,7 @@ impl eframe::App for WebShell { for f in dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); let target = app.drop_target(&name, pos, shift); - let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + let (inbox, place_inbox, repaint) = (self.host.inbox.dropped.clone(), self.host.place_inbox.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { match f.bytes_async().await { Ok(bytes) => { @@ -432,14 +444,14 @@ impl eframe::App for WebShell { } fn services( - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, ctx: egui::Context, locks: Option<WebLocks>, stylus: StylusFeed, ) -> Services { - let open_inbox = inbox.clone(); + let open_inbox = inbox.picked.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); let screen_color = eye_dropper(ctx.clone()); @@ -488,11 +500,12 @@ fn services( let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| { d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) }); - let Some(file) = dialog.pick_file().await else { - return; + // A canceled pick answers `None`. + let answer = match dialog.pick_file().await { + Some(file) => Some((file.file_name(), file.read().await)), + None => None, }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(answer); ctx.request_repaint(); }); })), @@ -500,7 +513,9 @@ fn services( screen_color, // A download that couldn't be started fails the save or export it is for (#1693). download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))), - inbox: Some(inbox), + inbox: Some(inbox.dropped), + picked_inbox: Some(inbox.picked), + page_inbox: Some(inbox.page), recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. print: Some(Box::new(BrowserPrint)), diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c6cf12857f..950adca0c8 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1523,7 +1523,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 8674942538..3bb451bfd6 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,5 @@ use super::{VectorcraftApp, io, place}; +use crate::services::Inbox; impl VectorcraftApp { /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs @@ -13,13 +14,25 @@ impl VectorcraftApp { self.services.dialogs_are_sheets && self.file_dialog_open() } - pub(super) fn drain_inbox(&mut self) { - let arrived: Vec<(String, Vec<u8>)> = - self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); - for (name, bytes) in arrived { - if let Err(e) = io::open_bytes(self, &name, &bytes, None) { - io::report_open_error(self, &name, &e); - } + pub(crate) fn drain_inbox(&mut self) { + fn take(q: Option<&Inbox>) -> Vec<(String, Vec<u8>)> { + q.map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default() + } + let answers = self.services.picked_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))); + // The web's file picker answers for automation when automation opened it; the page's script + // is automation. + let mut arrived: Vec<_> = take(self.services.inbox.as_ref()).into_iter().map(|f| (f, false)).collect(); + for answer in answers.unwrap_or_default() { + let automated = self.picks.answer_web_open(); + arrived.extend(answer.map(|f| (f, automated))); + } + arrived.extend(take(self.services.page_inbox.as_ref()).into_iter().map(|f| (f, true))); + for ((name, bytes), automated) in arrived { + crate::picks::as_automated(self, automated, |app| { + if let Err(e) = io::open_bytes(app, &name, &bytes, None) { + io::report_open_error(app, &name, &e); + } + }); } place::drain(self); } diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 8f4ce246f5..b27bb494aa 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -36,6 +36,13 @@ impl VectorcraftApp { self.ui.answering_control || self.synthetic_frame } + /// Did automation ask for what is being handled now: [`Self::automation_is_acting`], or the + /// answer of a file dialog that automation opened ([`crate::picks::as_automated`]), even when a + /// person picked the file in it? + pub(crate) fn automation_asked(&self) -> bool { + self.automation_is_acting() || self.picks.automated() + } + /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 88f9d35c80..54c98052bf 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -784,8 +784,7 @@ fn save_preset_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) /// ones → the names added /// (none on the web, where the picked file arrives later and imports through `io::open_bytes`). pub(crate) fn import_presets(app: &mut VectorcraftApp) -> Result<Vec<String>, String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(vec![]); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index e95d0e683a..a8bcdde6e4 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -197,8 +197,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Flattener presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 10f7e0e451..f29b25e9e1 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -173,8 +173,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index 7432bd3b2d..4772fa133b 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -191,8 +191,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 63158b7329..706188f6da 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -341,6 +341,21 @@ fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> /// The dialog kind of the approval of a plug-in install. pub const INSTALL_KIND: &str = "pluginInstall"; +/// Who asked for a plug-in install, fixed when the request is made. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InstallOrigin { + /// The person at the keyboard: Install Plug-in…, File › Open or a drop. + Person, + /// A control or MCP request, a frame of synthetic input, or a file dialog that one of them + /// opened ([`VectorcraftApp::automation_asked`]): nothing in the dialog installs it, and the + /// person starts Install Plug-in… to install it. + Automation, +} + +/// What [`ask_install`] answers, as `approval`, for a question automation asked: the person has to +/// start Install Plug-in… to install the module. +pub(crate) const PERSON_MUST_START_INSTALL: &str = "personMustStartInstall"; + /// A plug-in install waiting for a person's answer. pub(crate) struct PendingInstall { /// The question and the lines under it, in the UI language. @@ -350,6 +365,8 @@ pub(crate) struct PendingInstall { pub params: Value, /// What OK approves: the module the question is about. pub approval: InstallApproval, + /// Who asked; a question automation asked has no OK that installs. + pub origin: InstallOrigin, /// How long the question has been on screen; [`ask_install`] starts it over. pub shown: Shown, } @@ -439,27 +456,44 @@ pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { }; /// Asks a person whether to install what `pending` describes, its question not yet shown, even when -/// it reads as the one it replaces. → `{pending}`. +/// it reads as the one it replaces. → `{pending}`, and `approval: "personMustStartInstall"` when +/// automation asked. pub(crate) fn ask_install(app: &mut VectorcraftApp, mut pending: PendingInstall) -> Value { pending.shown = Shown::default(); + let automated = pending.origin == InstallOrigin::Automation; app.pending_plugin_install = Some(pending); app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); + if automated { + return json!({ "pending": INSTALL_KIND, "approval": PERSON_MUST_START_INSTALL }); + } json!({ "pending": INSTALL_KIND }) } +/// What the approval says when automation asked for the install; the error of its OK +/// ([`install_confirm`]) is the same text. +fn automation_asked_note() -> &'static str { + tl!("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.") +} + /// The question, the lines under it, and OK, which is disabled until the question has been shown -/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. +/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. When automation asked, a note +/// says so and OK stays disabled. fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction { let Some(pending) = app.pending_plugin_install.as_mut() else { return DialogAction::Cancel }; - let enabled = pending.shown.drawn(ui); + let person = pending.origin == InstallOrigin::Person; + let enabled = pending.shown.drawn(ui) && person; ui.label(egui::RichText::new(&pending.message).strong()); ui.add_space(8.0); ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + if !person { + ui.add_space(8.0); + ui.label(egui::RichText::new(automation_asked_note()).strong()); + } ui.add_space(16.0); let mut action = DialogAction::None; let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && pending.shown.ready() { + if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && person && pending.shown.ready() { action = DialogAction::Confirm; } pending.shown.released(ui); @@ -472,9 +506,12 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> } /// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the -/// dialog stays open. Before the question has been shown for [`APPROVAL_DELAY`] it installs nothing -/// and the dialog stays open. → `{pending}`. +/// dialog stays open, and so is any OK of a question automation asked. Before the question has been +/// shown for [`APPROVAL_DELAY`] it installs nothing and the dialog stays open. → `{pending}`. fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result<Value, String> { + if app.pending_plugin_install.as_ref().is_some_and(|p| p.origin == InstallOrigin::Automation) { + return Err("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.".into()); + } if app.automation_is_acting() { return Err("Only the person at the keyboard can approve installing a plug-in".into()); } diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index a8a9f42449..3ad798d998 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -153,8 +153,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Print presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 05756a1af6..69cc8939c7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5889,6 +5889,7 @@ axis Y Axis Y ос Version: {version} Версия: {version} Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация поиска инсталирането на тази приставка. За да я инсталирате, изберете Обект › Приставки › Инсталиране на приставка… сами. File SHA-256: {hash} SHA-256 на файла: {hash} Read the objects it is given Четене на обектите, които получава Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a9e705cfac..8e79e53757 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5910,6 +5910,8 @@ axis Y Axis Y osa Version: {version} Verzija: {version} Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane File SHA-256: {hash} SHA-256 datoteke: {hash} Read the objects it is given Čitati objekte koje dobije diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5341805a73..61040a40d9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6122,6 +6122,8 @@ digits Malayalam Malaiàlam Version: {version} Versió: {version} Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord File SHA-256: {hash} SHA-256 del fitxer: {hash} Read the objects it is given Llegir els objectes que rep diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9d6fd981e6..07ee218e20 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6075,6 +6075,8 @@ axis Y Axis osa Y Version: {version} Verze: {version} Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí File SHA-256: {hash} SHA-256 souboru: {hash} Read the objects it is given Číst objekty, které dostane diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index af95c4119d..0d5231c51e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5904,6 +5904,7 @@ axis Y Axis Y-akse Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om at installere dette plug-in. Vælg selv Objekt › Plug-ins › Installér plug-in… for at installere det. File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Læse de objekter, den får Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 55bc587b64..49326fa3e6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5658,6 +5658,8 @@ axis Y Axis Y-Achse Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt File SHA-256: {hash} Datei-SHA-256: {hash} Read the objects it is given Die Objekte lesen, die es erhält diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8732e8d34..1e09c4e1b5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6026,6 +6026,8 @@ axis Y Axis Άξονας Y Version: {version} Έκδοση: {version} Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό; This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης File SHA-256: {hash} SHA-256 αρχείου: {hash} Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7eb51ee4f2..3258fbbe3b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6017,6 +6017,8 @@ axis Y Axis Eje Y Version: {version} Versión: {version} Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte File SHA-256: {hash} SHA-256 del archivo: {hash} Read the objects it is given Leer los objetos que recibe diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433883a44f..1e160fb765 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6105,6 +6105,8 @@ axis Y Axis Y-akseli Version: {version} Versio: {version} Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy File SHA-256: {hash} Tiedoston SHA-256: {hash} Read the objects it is given Lukea sille annetut objektit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6995235edc..7ba0c27836 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6070,6 +6070,8 @@ axis Y Axis Axe Y Version: {version} Version : {version} Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 574229b446..2d725c3870 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5375,6 +5375,8 @@ antiAlias Sharp शार्प Version: {version} संस्करण: {version} Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं। + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें। +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें। @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें File SHA-256: {hash} फ़ाइल SHA-256: {hash} Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e4f100338..5ea9a53d71 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6058,6 +6058,8 @@ axis Y Axis Y tengely Version: {version} Verzió: {version} Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett File SHA-256: {hash} A fájl SHA-256 értéke: {hash} Read the objects it is given Olvashatja a kapott objektumokat diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a1df85189e..f051e4026e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5904,6 +5904,7 @@ axis Y Axis Sumbu Y Version: {version} Versi: {version} Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Otomatisasi meminta untuk menginstal plug-in ini. Untuk menginstalnya, pilih Objek › Plug-in › Instal plug-in… sendiri. File SHA-256: {hash} SHA-256 file: {hash} Read the objects it is given Membaca objek yang diberikan kepadanya Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c1400c91e3..288720af3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5692,6 +5692,8 @@ axis Y Axis Asse Y Version: {version} Versione: {version} Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente File SHA-256: {hash} SHA-256 del file: {hash} Read the objects it is given Leggere gli oggetti che riceve diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8a9e0cedc9..4d14c6e03d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5757,6 +5757,8 @@ axis Y Axis Y 軸 Version: {version} バージョン: {version} Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください File SHA-256: {hash} ファイルの SHA-256: {hash} Read the objects it is given 渡されたオブジェクトの読み取り diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83196486e7..d4b1316d6d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6026,6 +6026,8 @@ axis Y Axis Y ღერძი Version: {version} ვერსია: {version} Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით File SHA-256: {hash} ფაილის SHA-256: {hash} Read the objects it is given მიღებული ობიექტების წაკითხვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 060c421f28..92d07ede7d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5653,6 +5653,8 @@ axis Y Axis Y축 Version: {version} 버전: {version} Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요 File SHA-256: {hash} 파일 SHA-256: {hash} Read the objects it is given 전달받은 개체 읽기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e494230f8c..7a48ded8b6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5955,6 +5955,7 @@ axis Y Axis Y-as Version: {version} Versie: {version} Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Een automatisering heeft gevraagd deze plug-in te installeren. Kies zelf Object › Plug-ins › Plug-in installeren… om hem te installeren. File SHA-256: {hash} SHA-256 van het bestand: {hash} Read the objects it is given De objecten lezen die het krijgt Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 052d39351a..225d78fa0d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5929,6 +5929,8 @@ axis Y Axis Oś Y Version: {version} Wersja: {version} Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi File SHA-256: {hash} SHA-256 pliku: {hash} Read the objects it is given Odczytywać obiekty, które otrzymuje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f78a08845..22782896c4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5990,6 +5990,8 @@ axis Y Axis Eixo Y Version: {version} Versão: {version} Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar File SHA-256: {hash} SHA-256 do arquivo: {hash} Read the objects it is given Ler os objetos que recebe diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e50ebf68a0..1e2bd7ac62 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5686,6 +5686,8 @@ axis Y Axis Ось Y Version: {version} Версия: {version} Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится File SHA-256: {hash} SHA-256 файла: {hash} Read the objects it is given Читать переданные ему объекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 79725b4435..1118cc7a9f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6097,6 +6097,8 @@ axis Y Axis Y-axel Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Läsa objekten den får diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 05020e1676..46abcdd61d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6031,6 +6031,8 @@ axis Y Axis Y ekseni Version: {version} Sürüm: {version} Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin File SHA-256: {hash} Dosya SHA-256: {hash} Read the objects it is given Kendisine verilen nesneleri okuma diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13e60355b5..122e92737e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5672,6 +5672,8 @@ axis Y Axis Вісь Y Version: {version} Версія: {version} Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться File SHA-256: {hash} SHA-256 файлу: {hash} Read the objects it is given Читати передані йому об’єкти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cb5aaf727..4145d0b9b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5673,6 +5673,8 @@ axis Y Axis Trục Y Version: {version} Phiên bản: {version} Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý File SHA-256: {hash} SHA-256 của tệp: {hash} Read the objects it is given Đọc các đối tượng được trao cho nó diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 286b3a0ca4..0ed2c7475d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5913,6 +5913,8 @@ axis Y Axis Y 轴 Version: {version} 版本:{version} Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装 File SHA-256: {hash} 文件 SHA-256:{hash} Read the objects it is given 读取交给它的对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c819eccd10..ff9de758dd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5710,6 +5710,8 @@ axis Y Axis Y 軸 Version: {version} 版本:{version} Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝 File SHA-256: {hash} 檔案 SHA-256:{hash} Read the objects it is given 讀取交給它的物件 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 3ca34cdd77..4bdee0a6b6 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -215,16 +215,24 @@ fn permission_words(name: &str, commands: &[String]) -> String { /// place of another one with its id and get its settings and cache (`replace`); OK installs it if /// the module still has the hash the prompt showed, Cancel installs nothing → `{pending}` while it /// asks. The `grant` and `allowHeavy` of a request are ignored: only the dialog's OK approves. +/// Every question opens the approval dialog ([`dialogs::plugin::ask_install`]). When automation +/// asked ([`VectorcraftApp::automation_asked`]), the question has no OK that installs, its +/// `replace: true` doesn't skip asking about replacing, and the answer while it asks adds +/// `approval: "personMustStartInstall"`: the person starts Install Plug-in… to install it. /// Every text from the manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, String> { + let origin = if app.automation_asked() { dialogs::plugin::InstallOrigin::Automation } else { dialogs::plugin::InstallOrigin::Person }; + let automated = origin == dialogs::plugin::InstallOrigin::Automation; let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect()); let info = app.run("plugin.inspect", source)?; let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy"); - // `replace: false` asks for no replacing, so only a module without `replace` asks. - let asks_replace = module.get("replace").is_none_or(Value::is_null) && info["needsReplace"] == true; + // `replace: false` asks for no replacing, so only a module without `replace` asks, or one whose + // `replace: true` automation gave. + let replace = module.get("replace").filter(|v| !v.is_null()); + let asks_replace = info["needsReplace"] == true && replace.is_none_or(|r| automated && *r == true); if !needs_grant && !heavy && !asks_replace { let r = app.run("plugin.install", install)?; report_installed(app, &r); @@ -245,13 +253,8 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, if asks_replace { install["replace"] = json!(true); } - // A client may pass `replace` itself, so taking another module's place alone needs no approval - // that only a person can give. - if !needs_grant && !heavy { - let message = crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)]); - dialogs::confirm::ask(app, &message, &format!("{identity}\n\n{takes_place}"), "ui.installPlugin", install); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } + // Taking another module's place alone asks in the approval dialog too, whose params no request + // can rewrite. let asked = info["permissions"].as_array().cloned().unwrap_or_default(); let mut paragraphs = vec![]; if needs_grant { @@ -285,8 +288,10 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, } let message = if needs_grant { crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]) - } else { + } else if heavy { crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]) + } else { + crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)]) }; let approval = InstallApproval { id: info["id"].as_str().unwrap_or_default().to_string(), @@ -294,7 +299,8 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, permissions: asked.iter().filter_map(Value::as_str).map(str::to_string).collect(), heavy, }; - let pending = dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval, shown: Default::default() }; + let pending = + dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval, origin, shown: Default::default() }; Ok(dialogs::plugin::ask_install(app, pending)) } @@ -311,8 +317,7 @@ fn open_dialog_folder(app: &VectorcraftApp) -> Option<String> { /// File → Open… pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let path = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e636f0520a..fdb466171f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -120,7 +120,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, Services, + WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 19a6a1da67..489567bf38 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1524,7 +1524,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index ac92a42793..78ce76da32 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -827,8 +827,7 @@ pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result<Value, String /// Import Library…: a picked `.vclibrary` file becomes a new library (on the web the picked file /// arrives later through [`crate::io::open_bytes`]). fn import_picked(app: &mut VectorcraftApp) { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return; } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Libraries", LIBRARY_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index f7ee6020af..292fd498c5 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -584,10 +584,7 @@ pub(crate) fn pick_library_file( path: Option<String>, filters: Vec<(&'static str, &'static [&'static str])>, ) -> Result<Option<String>, String> { - if path.is_none() - && let Some(f) = app.services.open_async.as_mut() - { - f(); + if path.is_none() && crate::picks::open_async(app) { return Ok(None); } path.or_else(|| { diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..7699516d78 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -105,6 +105,9 @@ struct Waiting { /// The automation roots in force when it was asked for: the entry runs again under them, not /// with the desktop's access of the frame the answer comes in. scope: ScopeHandle, + /// Automation asked for it ([`VectorcraftApp::automation_asked`]): the entry runs again as + /// automation's ([`as_automated`]). + automated: bool, } /// The app's file dialogs ([`VectorcraftApp`]'s `picks`). @@ -122,6 +125,13 @@ pub(crate) struct Picks { replacing: Option<Replacing>, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option<String>, + /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). + automated: bool, + /// The web's file pickers opened ([`open_async`]) that have not answered yet + /// (`Services::picked_inbox`). + web_opens: u32, + /// Automation opened one of them: their answers are automation's. + web_open_automated: bool, } /// A save dialog's answer that names, once the app gave it an extension, a file that is there. @@ -152,6 +162,23 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.automated = false; + } + + /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). + pub(crate) fn automated(&self) -> bool { + self.automated + } + + /// One of the web's file pickers answered (a file, or canceled): whether the answer is + /// automation's. Once none is waiting, the next one the person opens answers for the person. + pub(crate) fn answer_web_open(&mut self) -> bool { + let automated = self.web_open_automated; + self.web_opens = self.web_opens.saturating_sub(1); + if self.web_opens == 0 { + self.web_open_automated = false; + } + automated } /// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`). @@ -188,6 +215,29 @@ pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run `f` as handling the answer of a file dialog automation opened when `automated` (and inside +/// one already, as that): [`VectorcraftApp::automation_asked`] is true meanwhile. +pub(crate) fn as_automated<R>(app: &mut VectorcraftApp, automated: bool, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.automated; + app.picks.automated = outer || automated; + let r = f(app); + app.picks.automated = outer; + r +} + +/// Opens the web's file picker, whose file arrives later through `Services::picked_inbox`, noting +/// whether automation asked for it ([`VectorcraftApp::automation_asked`]) → false when the host has +/// none. While a picker automation opened has not answered, every answer is automation's, also one +/// to a picker the person opened. +pub(crate) fn open_async(app: &mut VectorcraftApp) -> bool { + let automated = app.automation_asked(); + let Some(f) = app.services.open_async.as_mut() else { return false }; + f(); + app.picks.web_opens += 1; + app.picks.web_open_automated |= automated; + true +} + /// [`as_entry`] for a panel's or a dialog's button that `f` handles. pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { let f = Rc::new(f); @@ -319,8 +369,8 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> { match (app.picks.entry.clone(), app.services.start_pick.as_mut()) { (Some(entry), Some(start)) => { if let Some(answer) = start(request.clone()) { - let doc = entry.document(app); - app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture() }); + let (doc, automated) = (entry.document(app), app.automation_asked()); + app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture(), automated }); return None; } } @@ -367,7 +417,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { // The dialog's thread is gone: as cancelled. Err(TryRecvError::Disconnected) => vec![], }; - let Some(Waiting { request, entry, doc, scope, .. }) = app.picks.waiting.take() else { return }; + let Some(Waiting { request, entry, doc, scope, automated, .. }) = app.picks.waiting.take() else { return }; app.picks.refused = false; // Once more: what waited for the dialog (files from the Finder, the menu bar) goes on. ctx.request_repaint(); @@ -379,7 +429,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } app.picks.answer = Some((request, answer)); - rerun(app, entry); + as_automated(app, automated, |app| rerun(app, entry)); // An entry that took another way this time leaves the answer unused. app.picks.answer = None; }); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..03f098b0d9 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -47,6 +47,9 @@ pub type ExistsFn = Box<dyn Fn(&str) -> bool>; pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>; +/// The web open picker's answers in order: the picked file, or `None` when it was canceled. +pub type PickedInbox = Arc<Mutex<Vec<Option<(String, Vec<u8>)>>>>; + /// Starts a browser download of the bytes under a file name; an error when it couldn't be started /// (the save or export then fails as a refused write does: #1693). pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>; @@ -89,11 +92,17 @@ pub struct Services { /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). /// Without it, no file counts as there. pub exists: Option<ExistsFn>, - /// Files that arrived asynchronously (web open / drops): (name, bytes). + /// Files that arrived asynchronously (web drops): (name, bytes). pub inbox: Option<Inbox>, + /// Web: the open picker's answers (`open_async`). A file opens as automation's when automation + /// opened a picker that has not answered yet. + pub picked_inbox: Option<PickedInbox>, + /// Web: files the page's script handed over (`window.vectorcraft.openFile`). They open as + /// automation's. + pub page_inbox: Option<Inbox>, /// Web: trigger a browser download instead of writing a path. pub download: Option<DownloadFn>, - /// Web: start an async open (bytes arrive via `inbox`). + /// Web: start an async open (bytes arrive via `picked_inbox`). pub open_async: Option<Box<dyn FnMut()>>, /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with /// egui's Paste event (web, and keyboard paste everywhere). diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index ee876b2e71..df2fe29912 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -630,9 +630,11 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() { let _ = std::fs::remove_dir_all(base); } -/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes -/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs, -/// and a frame of synthetic input can't press its OK; the person at the keyboard can. +/// A plug-in's permissions are approved only by a person who asked for the install. A control +/// request (or MCP, which comes through it) can't pass `grant`, can't answer the approval dialog or +/// rewrite what it installs, and a frame of synthetic input can't press its OK. The person can't +/// approve the question automation asked either: they start Install Plug-in… themselves, which asks +/// again, and its OK installs. #[test] fn automation_cannot_approve_a_plugin_install() { let (tx, rx) = std::sync::mpsc::channel(); @@ -650,8 +652,9 @@ fn automation_cannot_approve_a_plugin_install() { let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); - refused(&r, "Only the person at the keyboard"); + refused(&r, ASKED); assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); @@ -662,22 +665,293 @@ fn automation_cannot_approve_a_plugin_install() { let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); assert!(r.get("error").is_none(), "{r}"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); - refused(&r, "Only the person at the keyboard"); + refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); - // Shown for long enough for a person's OK, it still refuses automation. + // Shown for long enough for a person's OK, it still refuses automation, and the person too. + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, ASKED); + app.synthetic_frame = true; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + app.synthetic_frame = false; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "a person's OK on automation's question"); + assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + + // The person starts Install Plug-in… with the file: a new question, which a person's OK approves + // once it was shown for the delay, and which automation still can't. + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question"); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["pending"], crate::dialogs::plugin::INSTALL_KIND, "not shown yet"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, "Only the person at the keyboard"); - app.synthetic_frame = true; - assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard")); - assert!(vectorcraft_plugins::registry::get(id).is_none()); - app.synthetic_frame = false; - crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// The refusal of an OK on an install question automation asked. +const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; + +/// The approval module `id` that lists `doc.read`, and its bytes as base64. +fn approval_module(id: &str) -> (Vec<u8>, String) { + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + (bytes, data) +} + +/// An install question that a control request asked keeps OK disabled in the person's window, even +/// once it was shown for the delay: a person's click, Enter or Space install nothing, and the dialog +/// says to start Install Plug-in…. Cancel still closes it at once. +#[test] +fn a_person_cannot_approve_an_install_question_automation_asked() { + use super::plugins::{at, click_ok, ok_enabled, waiting, window}; + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.asked"; + let (tx, rx) = std::sync::mpsc::channel(); + let app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let mut h = window(app); + let (_, data) = approval_module(id); + let r = request(h.state_mut(), &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(!ok_enabled(&h), "OK stays disabled past the delay"); + assert!(h.query_by_label(ASKED).is_some(), "the dialog says what to do"); + click_ok(&mut h, 10.7); + at(&mut h, 10.8); + assert!(waiting(h.state(), id), "a person's click"); + for (key, time) in [(egui::Key::Enter, 10.9), (egui::Key::Space, 11.0)] { + h.key_press(key); + at(&mut h, time); + assert!(waiting(h.state(), id), "{key:?}"); + } + assert!(crate::dialogs::confirm(h.state_mut()).unwrap_err().contains(ASKED), "a confirm that skips the button"); + assert!(waiting(h.state(), id)); + h.get_by_label("Cancel").click(); + at(&mut h, 11.1); + assert!(h.state().ui.dialog.is_none() && h.state().pending_plugin_install.is_none(), "Cancel"); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); +} + +/// A question automation asks in place of the one a person asked can't be approved: it keeps +/// automation's origin, though the person's question was shown and ready. +#[test] +fn automation_replacing_a_persons_install_question_does_not_take_its_approval() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let (mine, theirs) = ("test.ui.approval.mine", "test.ui.approval.theirs"); + app.run("ui.installPlugin", json!({"dataBase64": approval_module(mine).1})).unwrap(); + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(theirs).1}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + for id in [mine, theirs] { + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none(), "{id}"); + } + // Asked again by automation with the same file, it is still automation's. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(mine).1}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + assert!(vectorcraft_plugins::registry::get(mine).is_none()); +} + +/// A file a person picks in the file dialog that a control request opened (Install Plug-in… or +/// File › Open with no path) is automation's request: its question can't be approved. The file +/// dialog a person opens asks a question they can approve. On the desktop the dialog answers later +/// ([`crate::picks`]); on the web the file arrives through the picker's inbox, apart from drops and +/// the page's script, which is automation. +#[test] +fn a_file_picked_in_a_dialog_automation_opened_stays_automations() { + let base = std::env::temp_dir().join(format!("vc-ui-approval-picked-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let (desktop, web) = ("test.ui.approval.picked", "test.ui.approval.picked.web"); + let (bytes, _) = approval_module(desktop); + let wasm = base.join("picked.wasm"); + std::fs::write(&wasm, &bytes).unwrap(); + let mut services = Services::default(); + let (shown, _) = super::picks::off_the_ui_thread(&mut services); + services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let pick = |app: &mut VectorcraftApp| { + shown.answer(&[&s(wasm.clone())]); + crate::picks::poll(app, &egui::Context::default()); + }; + for command in ["ui.installPlugin", "file.open"] { + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": command, "params": {}})); + assert!(app.file_dialog_open(), "{command}: {r}"); + pick(&mut app); + assert!(super::plugins::waiting(&app, desktop), "{command}: {:?}", app.ui.status); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{command}"); + crate::dialogs::cancel(&mut app); + } + let _ = app.run("ui.installPlugin", json!({})); + assert!(app.file_dialog_open()); + pick(&mut app); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(desktop).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + app.run("plugin.remove", json!({"id": desktop})).unwrap(); + + // The web: the browser's file picker answers through its own inbox, apart from the files a + // person drops and the ones the page's script hands over. + let (dropped, page) = <(crate::services::Inbox, crate::services::Inbox)>::default(); + let picked = crate::services::PickedInbox::default(); + let services = Services { + open_async: Some(Box::new(|| {})), + inbox: Some(dropped.clone()), + picked_inbox: Some(picked.clone()), + page_inbox: Some(page.clone()), + ..Default::default() + }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let (by_drop, by_page) = ("test.ui.approval.picked.dropped", "test.ui.approval.picked.page"); + let file = |id: &str| (format!("{id}.wasm"), approval_module(id).0); + let hand_over = |app: &mut VectorcraftApp, inbox: &crate::services::Inbox, id: &str| { + inbox.lock().unwrap().push(file(id)); + app.drain_inbox(); + }; + // The picker answers with a file, or `None` when it is canceled. + let answer = |app: &mut VectorcraftApp, id: Option<&str>| { + picked.lock().unwrap().push(id.map(file)); + app.drain_inbox(); + }; + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}})); + assert!(r.get("error").is_none(), "{r}"); + // A file the person drops before the picker answers stays the person's. + hand_over(&mut app, &dropped, by_drop); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(by_drop).is_some()); + answer(&mut app, Some(web)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the web"); + crate::dialogs::cancel(&mut app); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(web)); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(web).is_some()); + // Opening the picker again as the person before it answers automation leaves the answer + // automation's. + let (again, after) = ("test.ui.approval.picked.again", "test.ui.approval.picked.after"); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}})); + assert!(r.get("error").is_none(), "{r}"); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(again)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "opened again"); + crate::dialogs::cancel(&mut app); + // Once the other picker is canceled, the next one the person opens answers for them again. + answer(&mut app, None); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(after)); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(after).is_some()); + // The page's script is automation. + hand_over(&mut app, &page, by_page); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the page's script"); + crate::dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::granted(by_page).is_none()); + for id in [web, by_drop, after] { + app.run("plugin.remove", json!({"id": id})).unwrap(); + } + let _ = std::fs::remove_dir_all(base); +} + +/// A `.wasm` that a control request opens by path (`app.open`) asks a question that can't be +/// approved; one the person opens can be. +#[test] +fn a_plugin_automation_opened_by_path_asks_a_question_no_one_approves() { + let base = std::env::temp_dir().join(format!("vc-ui-approval-open-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let id = "test.ui.approval.opened"; + let (bytes, _) = approval_module(id); + let wasm = base.join("opened.wasm"); + std::fs::write(&wasm, &bytes).unwrap(); + let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let r = request(&mut app, &tx, "app.open", json!({"path": s(wasm.clone())})); + assert!(r.get("error").is_none(), "{r}"); + assert!(super::plugins::waiting(&app, id)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + crate::dialogs::cancel(&mut app); + crate::io::open_path(&mut app, &s(wasm.clone())).unwrap(); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(id).is_some()); + app.run("plugin.remove", json!({"id": id})).unwrap(); + let _ = std::fs::remove_dir_all(base); +} + +/// A module that takes the place of another with its id, and asks for nothing else: automation's +/// `replace: true` doesn't skip the question and its question can't be approved. The question a +/// person asked keeps what it installs out of reach of `ui.dialog.set`, refuses a control confirm, +/// and the person's own OK replaces. +#[test] +fn automation_cannot_approve_replacing_a_plugin() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let id = "test.ui.approval.replaced"; + let plain = |output: &str| { + wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{id}","name":"Plain","kind":"filter"}}"#), output)).unwrap() + }; + let (first, other) = (plain(r#"{"objects":[]}"#), plain(r#"{"objects":[],"error":null}"#)); + let installed = || vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()); + let first_hash = vectorcraft_plugins::permissions::sha256_hex(&first); + let data = vectorcraft_format::base64_encode(&other); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&first)})).unwrap(); + assert_eq!(installed(), Some(first_hash.clone())); + + for params in [json!({"dataBase64": data}), json!({"dataBase64": data, "replace": true})] { + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{params}: {r}"); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{params}: {r}"); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{params}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + assert!(r["error"].as_str().is_some_and(|e| e.contains(ASKED)), "{r}"); + crate::dialogs::cancel(&mut app); + assert_eq!(installed(), Some(first_hash.clone()), "{params}"); + } + + // The person's question: a control request can't point its OK at other bytes, nor press it. + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); + let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); + assert!(r.get("error").is_none(), "{r}"); + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); + assert_eq!(installed(), Some(first_hash.clone())); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(installed(), Some(vectorcraft_plugins::permissions::sha256_hex(&other)), "the bytes the question showed"); + app.run("plugin.remove", json!({"id": id})).unwrap(); + // A module that asks for nothing still installs at once when automation asks. + let free = "test.ui.approval.free"; + let module = wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{free}","name":"Free","kind":"filter"}}"#), "{}")).unwrap(); + let params = json!({"dataBase64": vectorcraft_format::base64_encode(&module)}); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params})); + assert!(r["result"]["pending"].is_null() && app.ui.dialog.is_none(), "{r}"); + assert!(vectorcraft_plugins::registry::get(free).is_some()); + app.run("plugin.remove", json!({"id": free})).unwrap(); +} + type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>; /// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken @@ -702,10 +976,10 @@ fn still_waiting(h: &DialogHarness, id: &str) -> bool { && vectorcraft_plugins::registry::granted(id).is_none() } -/// Keys and pointer input sent through the control path approve nothing at the open install -/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a -/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on -/// the same button approves. +/// Keys and pointer input sent through the control path approve nothing at the install approval a +/// person asked for: Enter and Space press nothing with no button focused, and with OK focused they +/// and a `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click +/// on the same button approves. #[test] fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { use egui_kittest::kittest::{NodeT, Queryable}; @@ -717,8 +991,8 @@ fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); let data = vectorcraft_format::base64_encode(&bytes); - let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); - assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question"); let mut fonts = false; let mut h: DialogHarness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 105c223261..0a80254db0 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -45,11 +45,11 @@ pub(super) fn show_approval(app: &mut VectorcraftApp) { panic!("the approval's OK never took input"); } -type Window = egui_kittest::Harness<'static, VectorcraftApp>; +pub(super) type Window = egui_kittest::Harness<'static, VectorcraftApp>; /// The dialog layer of `app` in a window, each frame drawn at the time [`at`] gives it. Building it /// draws frames at times of its own, so a test opens the dialog after. -fn window(app: VectorcraftApp) -> Window { +pub(super) fn window(app: VectorcraftApp) -> Window { let mut fonts = false; egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { @@ -64,26 +64,26 @@ fn window(app: VectorcraftApp) -> Window { } /// Draws a frame of `h` at `time` seconds. -fn at(h: &mut Window, time: f64) { +pub(super) fn at(h: &mut Window, time: f64) { h.input_mut().time = Some(time); h.step(); } /// Whether the approval's OK is enabled, as a screen reader reads it. -fn ok_enabled(h: &Window) -> bool { +pub(super) fn ok_enabled(h: &Window) -> bool { use egui_kittest::kittest::{NodeT, Queryable}; !h.get_by_label("OK").accesskit_node().is_disabled() } /// Clicks the approval's OK as a person does, on a frame at `time` seconds. -fn click_ok(h: &mut Window, time: f64) { +pub(super) fn click_ok(h: &mut Window, time: f64) { use egui_kittest::kittest::Queryable; h.get_by_label("OK").click(); at(h, time); } /// The approval is open and plug-in `id` neither installed nor granted. -fn waiting(app: &VectorcraftApp, id: &str) -> bool { +pub(super) fn waiting(app: &VectorcraftApp, id: &str) -> bool { app.ui.dialog.as_ref().is_some_and(|d| d.kind == dialogs::plugin::INSTALL_KIND) && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none() @@ -184,7 +184,7 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { /// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with /// a bidirectional override, a tab and a bell in it. -fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> { +pub(super) fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> { let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string(); let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}')); wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() @@ -522,9 +522,8 @@ fn the_install_approval_waits_again_when_the_focus_leaves_and_comes_back_between h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); } -/// A module with other bytes than the one installed with its id asks before taking its place: in -/// the ordinary confirm when it needs nothing that only a person can approve, and in the approval -/// dialog, with its id and file hash, when it also asks for a heavy budget. +/// A module with other bytes than the one installed with its id asks before taking its place, in +/// the approval dialog, with its id and file hash, also when it asks for a heavy budget. #[test] fn installing_a_plugin_over_another_module_asks_first() { let mut app = app(); @@ -538,10 +537,13 @@ fn installing_a_plugin_over_another_module_asks_first() { let other = plain(r#"{"objects":[],"error":null}"#); let other_hash = vectorcraft_plugins::permissions::sha256_hex(&other); let r = app.run("ui.installPlugin", module(&other)).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), "Install “Plain” in place of the plug-in with the same ID?"); - assert_eq!(d.str("detail"), format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + assert_eq!(r, json!({"pending": dialogs::plugin::INSTALL_KIND})); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?"); + assert_eq!(pending.detail, format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + assert_eq!(dialogs::confirm(&mut app).unwrap()["pending"], dialogs::plugin::INSTALL_KIND, "it waits like any other question"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(other_hash)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dbaf2b48ab..bf9ac19bca 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -217,7 +217,9 @@ export's own parameters); `ui.dialog.confirm` writes the file. An importer plug- `pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open` (`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without a path it asks for one); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm -(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md). +(`ui.dialog.confirm` and injected input are refused). When a control request opened it, directly or through a file +dialog, the reply adds `approval: "personMustStartInstall"` and nobody can confirm it: the person chooses Object › +Plug-ins › Install Plug-in… to install the plug-in; see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, @@ -1059,7 +1061,7 @@ app: | `vectorcraft.docInspect(params)` / `vectorcraft.uiInspect(params)` | `document.inspect` / `ui.inspect` | | `vectorcraft.inspect()` | `ui.inspect` (the name before `uiInspect`) | | `vectorcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline, as `data` and as `pngBase64`) | -| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens (returns `true` once queued) | +| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens, as automation: a plug-in install it asks for has to be started by the person (returns `true` once queued) | | `vectorcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded | | `vectorcraft.files()` / `vectorcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` | diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..a32c7e5b9b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8009,7 +8009,10 @@ needs a person's approval too, and `plugin.install` refuses `grant: [permission] app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`, `ui.dialog.confirm` and injected input can't confirm it. Its OK takes input only once the question has been on screen in the focused window for half a second, counted again for every new question and after the window gets the focus -back. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` +back. A question that a request asked, directly or through a file dialog it opened, can't be approved by the person +either: the reply adds `approval: "personMustStartInstall"`, OK stays disabled, and the person installs the plug-in +with Object › Plug-ins › Install Plug-in… themselves. A request's `replace: true` doesn't skip the question about +taking another module's place. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with `--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install diff --git a/docs/plugins.md b/docs/plugins.md index dacf59b218..410b20719c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -217,7 +217,16 @@ capability model proposed for PhotoCraft plug-ins https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel - and Escape work at once. + and Escape work at once. A module that would take the place of the installed plug-in with its id asks in the same + dialog. +- **An install that automation asked for is not approved in its dialog.** When a control request, MCP, injected input, + or a file dialog that one of them opened (even when the person picks the file there) leads to the approval dialog, its + OK stays disabled, a confirm is refused even after the half second, and the dialog says that automation asked for the + install. `ui.installPlugin` returns `{pending: "pluginInstall", approval: "personMustStartInstall"}`. Cancel still + closes it. To install the plug-in, the person chooses Object › Plug-ins › Install Plug-in… and picks the file, or + opens it with File › Open or drops it on the window: the module is inspected again and the new question waits its + half second. A request's `replace: true` does not skip asking about taking another module's place. A module that + needs no approval still installs at once from any request. This is VectorCraft's own rule. - `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant (`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..99c991d493 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1695,6 +1695,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. From 512627b39f013dc14816482030abab91daecfa04 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:59:27 -0800 Subject: [PATCH 074/126] Docs: the DXF CNC / CAM preset is listed as shipped docs/gaps.md, docs/file-format-parity.md and docs/ui-parity.md still listed the CNC preset as open after #5500 added it. They now name DXF Options' Preset menu and dxfPreset: "cnc" on document.exportDxf; VCarve QA stays open. The ui-parity row also no longer lists native CIRCLE/ARC export as pending. --- docs/file-format-parity.md | 2 +- docs/gaps.md | 4 ++-- docs/ui-parity.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa682c0272..60cd24ace1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -290,7 +290,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | -| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | +| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | diff --git a/docs/gaps.md b/docs/gaps.md index 1ce258a111..3082873bb4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -323,8 +323,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). - **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58e5276573..1471590da0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -436,7 +436,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | -| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | +| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500) | | macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet | | Preferences: every option acts | partial | 2 stored but unused (#394, audited 2026-10-30): Scratch Disks, Legacy vector clipboard; the rest act (Units › East Asian Type since 2026-10-31; Open Documents As Tabs: cleared, new documents open in floating windows). General › Corner Radius drives the Rounded Rectangle tool and follows its ↑/↓ (2026-10-24) | | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned | From da97956b451e899be8d7d78561f94f9a6e940782 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:03:57 -0800 Subject: [PATCH 075/126] Plug-ins: a wrongly shaped `commands` value gets an error every catalog translates A manifest whose `commands` value is not a list, whose entry is not an object, or whose command `id` or `label` is not a string was refused with serde's own text ("invalid type: ..., expected command [0] as {id, label, params?}"), and no catalog had rows for it or for the reader's other `commands` messages. The reader now gives its own message for each of these cases, and the complete-message catalogs translate them, together with "missing field", "duplicate field" and the manifest prefix with its line and column. --- crates/plugins/src/manifest.rs | 83 +++++++++++++++++++++++++---- crates/ui-egui/src/i18n/ca.tsv | 6 +++ crates/ui-egui/src/i18n/cs.tsv | 6 +++ crates/ui-egui/src/i18n/de.tsv | 6 +++ crates/ui-egui/src/i18n/el.tsv | 6 +++ crates/ui-egui/src/i18n/es.tsv | 6 +++ crates/ui-egui/src/i18n/fi.tsv | 6 +++ crates/ui-egui/src/i18n/fr.tsv | 6 +++ crates/ui-egui/src/i18n/hi.tsv | 5 ++ crates/ui-egui/src/i18n/hu.tsv | 6 +++ crates/ui-egui/src/i18n/it.tsv | 6 +++ crates/ui-egui/src/i18n/ja.tsv | 6 +++ crates/ui-egui/src/i18n/ka.tsv | 6 +++ crates/ui-egui/src/i18n/ko.tsv | 6 +++ crates/ui-egui/src/i18n/pl.tsv | 6 +++ crates/ui-egui/src/i18n/pt-br.tsv | 6 +++ crates/ui-egui/src/i18n/ru.tsv | 6 +++ crates/ui-egui/src/i18n/sv.tsv | 5 ++ crates/ui-egui/src/i18n/tests.rs | 32 +++++++++++ crates/ui-egui/src/i18n/tr.tsv | 6 +++ crates/ui-egui/src/i18n/uk.tsv | 6 +++ crates/ui-egui/src/i18n/vi.tsv | 6 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 +++ 24 files changed, 235 insertions(+), 10 deletions(-) diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index a16bd8a4f9..b6e17e3ad0 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -736,10 +736,41 @@ fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Ve /// Reads the `commands` list one entry at a time from the manifest itself, so each command's /// `params` keep their declaration order and a repeated parameter is refused. A malformed entry /// is an error that names it (its id when the id comes before the fault, otherwise its place in -/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`]. +/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`]. A `commands` value, +/// an entry, an `id` or a `label` of the wrong type gets a fixed message of this function's own. fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<Command>, D::Error> { use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}; + /// A visitor's methods for a boolean, a number and a string: each is the error its type's + /// `wrong()` gives. + macro_rules! refuse_scalars { + () => { + fn visit_bool<E: serde::de::Error>(self, _: bool) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_str<E: serde::de::Error>(self, _: &str) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + }; + } + + /// A string field of an entry: anything else is the error "`{key}` must be a string". + fn string_field<'de, A: MapAccess<'de>>(map: &mut A, key: &str) -> std::result::Result<std::result::Result<String, String>, A::Error> { + Ok(match map.next_value::<Value>()? { + Value::String(s) => Ok(s), + _ => Err(format!("`{key}` must be a string")), + }) + } + /// A command's `params`, read in declaration order. struct Params(Vec<(String, Param)>); impl<'de> Deserialize<'de> for Params { @@ -750,6 +781,11 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu /// The entry at place `.0` in the list. struct Entry(usize); + impl Entry { + fn wrong(&self) -> String { + format!("command {} must be an object with an `id` and a `label`", command_name(self.0, "")) + } + } impl<'de> serde::de::DeserializeSeed<'de> for Entry { type Value = Command; fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Command, D::Error> { @@ -759,21 +795,29 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu impl<'de> Visitor<'de> for Entry { type Value = Command; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "command [{}] as {{id, label, params?}}", self.0) + f.write_str(&self.wrong()) + } + refuse_scalars!(); + fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Command, E> { + Err(E::custom(self.wrong())) + } + fn visit_seq<A: SeqAccess<'de>>(self, _: A) -> std::result::Result<Command, A::Error> { + Err(A::Error::custom(self.wrong())) } fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> std::result::Result<Command, A::Error> { let (mut id, mut label, mut params) = (None::<String>, None::<String>, None::<Params>); let named = |id: &Option<String>, e: &dyn std::fmt::Display| { A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default()))) }; - while let Some(key) = map.next_key::<String>().map_err(|e| named(&id, &e))? { + // A key and an ignored value can only fail as JSON syntax, which keeps serde's own error. + while let Some(key) = map.next_key::<String>()? { let repeated = match key.as_str() { "id" if id.is_none() => { - id = Some(map.next_value().map_err(|e| named(&id, &e))?); + id = Some(string_field(&mut map, "id")?.map_err(|e| named(&id, &e))?); false } "label" if label.is_none() => { - label = Some(map.next_value().map_err(|e| named(&id, &e))?); + label = Some(string_field(&mut map, "label")?.map_err(|e| named(&id, &e))?); false } "params" if params.is_none() => { @@ -782,7 +826,7 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu } "id" | "label" | "params" => true, _ => { - map.next_value::<IgnoredAny>().map_err(|e| named(&id, &e))?; + map.next_value::<IgnoredAny>()?; false } }; @@ -801,16 +845,22 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu } struct List; + impl List { + fn wrong(&self) -> &'static str { + "`commands` must be a list of commands, each with an `id` and a `label`" + } + } impl<'de> Visitor<'de> for List { type Value = Vec<Command>; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("`commands` as a list of {id, label, params?}") + f.write_str(self.wrong()) } + refuse_scalars!(); fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> { Ok(Vec::new()) } fn visit_map<A: MapAccess<'de>>(self, _: A) -> std::result::Result<Self::Value, A::Error> { - Err(A::Error::custom("`commands` must be a list of {id, label, params?}")) + Err(A::Error::custom(self.wrong())) } fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> { let mut out = Vec::new(); @@ -1215,14 +1265,27 @@ mod tests { r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#, "command `x`: parameter `r` is declared twice", ), - (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), - (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0] must be an object with an `id` and a `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},null]}"#, "command [1] must be an object"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[["x"]]}"#, "command [0] must be an object"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":1,"label":"A"}]}"#, "command [0]: `id` must be a string"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":true}]}"#, "command `x`: `label` must be a string"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","id":"y","label":"A"}]}"#, "command `x`: duplicate field `id`"), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, + "`commands` must be a list of commands, each with an `id` and a `label`", + ), + (r#"{"id":"a","name":"x","kind":"filter","commands":"x"}"#, "`commands` must be a list of commands"), + (r#"{"id":"a","name":"x","kind":"filter","commands":7}"#, "`commands` must be a list of commands"), + (r#"{"id":"a","name":"x","kind":"filter","commands":true}"#, "`commands` must be a list of commands"), (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), (r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"), (too_many.as_str(), "at most 32 `commands`"), ] { let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); assert!(e.contains(says), "{bad}: {e}"); + // The message is the manifest reader's own, with serde's place in the manifest once. + assert!(!e.contains("invalid type") && !e.contains("expected") && e.matches(" at line ").count() <= 1, "{bad}: {e}"); } let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1)); assert!(Manifest::parse(long_id.as_bytes()).is_err()); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..fbc3eeaa4a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6168,6 +6168,12 @@ digits Malayalam Malaiàlam @msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra @msg command {name}: {e} ordre {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (línia {line}, columna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` ha de ser una llista d’ordres, cadascuna amb `id` i `label` +@msg command {name} must be an object with an `id` and a `label` l’ordre {name} ha de ser un objecte amb `id` i `label` +@msg missing field `{key}` falta el camp `{key}` +@msg duplicate field `{key}` el camp `{key}` està repetit @msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..7e6266300b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6121,6 +6121,12 @@ axis Y Axis osa Y @msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem @msg command {name}: {e} příkaz {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (řádek {line}, sloupec {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` musí být seznam příkazů, každý s `id` a `label` +@msg command {name} must be an object with an `id` and a `label` příkaz {name} musí být objekt s `id` a `label` +@msg missing field `{key}` chybí pole `{key}` +@msg duplicate field `{key}` pole `{key}` je uvedeno dvakrát @msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..8acfb4e263 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5704,6 +5704,12 @@ axis Y Axis Y-Achse @msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt @msg command {name}: {e} Befehl {name}: {e} +@msg manifest: {e} Manifest: {e} +@msg manifest: {e} at line {line} column {column} Manifest: {e} (Zeile {line}, Spalte {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` muss eine Liste von Befehlen sein, jeder mit `id` und `label` +@msg command {name} must be an object with an `id` and a `label` der Befehl {name} muss ein Objekt mit `id` und `label` sein +@msg missing field `{key}` fehlendes Feld `{key}` +@msg duplicate field `{key}` doppeltes Feld `{key}` @msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..f44b227720 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6072,6 +6072,12 @@ axis Y Axis Άξονας Y @msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα @msg command {name}: {e} εντολή {name}: {e} +@msg manifest: {e} αρχείο δηλώσεων: {e} +@msg manifest: {e} at line {line} column {column} αρχείο δηλώσεων: {e} (γραμμή {line}, στήλη {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` το `commands` πρέπει να είναι λίστα εντολών, καθεμία με `id` και `label` +@msg command {name} must be an object with an `id` and a `label` η εντολή {name} πρέπει να είναι αντικείμενο με `id` και `label` +@msg missing field `{key}` λείπει το πεδίο `{key}` +@msg duplicate field `{key}` το πεδίο `{key}` εμφανίζεται δύο φορές @msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..516d36cad4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6063,6 +6063,12 @@ axis Y Axis Eje Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifiesto: {e} +@msg manifest: {e} at line {line} column {column} manifiesto: {e} (línea {line}, columna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` debe ser una lista de comandos, cada uno con `id` y `label` +@msg command {name} must be an object with an `id` and a `label` el comando {name} debe ser un objeto con `id` y `label` +@msg missing field `{key}` falta el campo `{key}` +@msg duplicate field `{key}` el campo `{key}` está repetido @msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..2ddd2458cc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6151,6 +6151,12 @@ axis Y Axis Y-akseli @msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella @msg command {name}: {e} komento {name}: {e} +@msg manifest: {e} manifesti: {e} +@msg manifest: {e} at line {line} column {column} manifesti: {e} (rivi {line}, sarake {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`-kohdan on oltava luettelo komennoista, joilla kullakin on `id` ja `label` +@msg command {name} must be an object with an `id` and a `label` komennon {name} on oltava objekti, jossa on `id` ja `label` +@msg missing field `{key}` kenttä `{key}` puuttuu +@msg duplicate field `{key}` kenttä `{key}` on kahdesti @msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan @msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..48e3e5f884 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6116,6 +6116,12 @@ axis Y Axis Axe Y @msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre @msg command {name}: {e} commande {name} : {e} +@msg manifest: {e} manifeste : {e} +@msg manifest: {e} at line {line} column {column} manifeste : {e} (ligne {line}, colonne {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` doit être une liste de commandes, chacune avec `id` et `label` +@msg command {name} must be an object with an `id` and a `label` la commande {name} doit être un objet avec `id` et `label` +@msg missing field `{key}` champ `{key}` manquant +@msg duplicate field `{key}` champ `{key}` en double @msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` @msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..5a706ff7b6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5419,6 +5419,11 @@ antiAlias Sharp शार्प @msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो @msg command {name}: {e} कमांड {name}: {e} +@msg manifest: {e} at line {line} column {column} प्रकटन: {e} (लाइन {line}, कॉलम {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` कमांड की एक सूची होनी चाहिए, हर एक में `id` और `label` हो +@msg command {name} must be an object with an `id` and a `label` कमांड {name} एक ऑब्जेक्ट होना चाहिए जिसमें `id` और `label` हों +@msg missing field `{key}` फ़ील्ड `{key}` नहीं है +@msg duplicate field `{key}` फ़ील्ड `{key}` दो बार है @msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..889dda0557 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6104,6 +6104,12 @@ axis Y Axis Y tengely @msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót @msg command {name}: {e} {name} parancs: {e} +@msg manifest: {e} leíró: {e} +@msg manifest: {e} at line {line} column {column} leíró: {e} ({line}. sor, {column}. oszlop) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`: parancsok listája kell, mindegyikben `id` és `label` értékkel +@msg command {name} must be an object with an `id` and a `label` a(z) {name} parancs csak `id` és `label` mezőt tartalmazó objektum lehet +@msg missing field `{key}` hiányzó mező: `{key}` +@msg duplicate field `{key}` ismétlődő mező: `{key}` @msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..dc980a96b1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5738,6 +5738,12 @@ axis Y Axis Asse Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (riga {line}, colonna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` deve essere un elenco di comandi, ciascuno con `id` e `label` +@msg command {name} must be an object with an `id` and a `label` il comando {name} deve essere un oggetto con `id` e `label` +@msg missing field `{key}` campo `{key}` mancante +@msg duplicate field `{key}` campo `{key}` duplicato @msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..2c27451848 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5803,6 +5803,12 @@ axis Y Axis Y 軸 @msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください @msg command {name}: {e} コマンド {name}: {e} +@msg manifest: {e} マニフェスト: {e} +@msg manifest: {e} at line {line} column {column} マニフェスト: {e} ({line} 行目、{column} 列目) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` は、それぞれ `id` と `label` を持つコマンドのリストにしてください +@msg command {name} must be an object with an `id` and a `label` コマンド {name} は `id` と `label` を持つオブジェクトにしてください +@msg missing field `{key}` フィールド `{key}` がありません +@msg duplicate field `{key}` フィールド `{key}` が重複しています @msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです @msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..928057376c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6072,6 +6072,12 @@ axis Y Axis Y ღერძი @msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება @msg command {name}: {e} ბრძანება {name}: {e} +@msg manifest: {e} მანიფესტი: {e} +@msg manifest: {e} at line {line} column {column} მანიფესტი: {e} (სტრიქონი {line}, სვეტი {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` უნდა იყოს ბრძანებების სია, თითოეული `id`-ით და `label`-ით +@msg command {name} must be an object with an `id` and a `label` ბრძანება {name} უნდა იყოს ობიექტი `id`-ით და `label`-ით +@msg missing field `{key}` აკლია ველი `{key}` +@msg duplicate field `{key}` ველი `{key}` ორჯერაა @msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..1afa4ae7be 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5699,6 +5699,12 @@ axis Y Axis Y축 @msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 @msg command {name}: {e} 명령 {name}: {e} +@msg manifest: {e} 매니페스트: {e} +@msg manifest: {e} at line {line} column {column} 매니페스트: {e} ({line}행 {column}열) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`는 각각 `id`와 `label`이 있는 명령 목록이어야 합니다 +@msg command {name} must be an object with an `id` and a `label` 명령 {name}은(는) `id`와 `label`이 있는 객체여야 합니다 +@msg missing field `{key}` `{key}` 필드가 없습니다 +@msg duplicate field `{key}` `{key}` 필드가 중복되었습니다 @msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..6e0e81e119 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5975,6 +5975,12 @@ axis Y Axis Oś Y @msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery @msg command {name}: {e} polecenie {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (wiersz {line}, kolumna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` musi być listą poleceń, każde z `id` i `label` +@msg command {name} must be an object with an `id` and a `label` polecenie {name} musi być obiektem z `id` i `label` +@msg missing field `{key}` brak pola `{key}` +@msg duplicate field `{key}` zduplikowane pole `{key}` @msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..17a9a9ad16 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6036,6 +6036,12 @@ axis Y Axis Eixo Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifesto: {e} +@msg manifest: {e} at line {line} column {column} manifesto: {e} (linha {line}, coluna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` deve ser uma lista de comandos, cada um com `id` e `label` +@msg command {name} must be an object with an `id` and a `label` o comando {name} deve ser um objeto com `id` e `label` +@msg missing field `{key}` falta o campo `{key}` +@msg duplicate field `{key}` campo `{key}` duplicado @msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..e54259e447 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5732,6 +5732,12 @@ axis Y Axis Ось Y @msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы @msg command {name}: {e} команда {name}: {e} +@msg manifest: {e} манифест: {e} +@msg manifest: {e} at line {line} column {column} манифест: {e} (строка {line}, столбец {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` должно быть списком команд, у каждой из которых есть `id` и `label` +@msg command {name} must be an object with an `id` and a `label` команда {name} должна быть объектом с `id` и `label` +@msg missing field `{key}` нет поля `{key}` +@msg duplicate field `{key}` поле `{key}` указано дважды @msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..df5afb54a8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6140,6 +6140,11 @@ axis Y Axis Y-axel @msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav @msg command {name}: {e} kommando {name}: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (rad {line}, kolumn {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` måste vara en lista med kommandon, vart och ett med `id` och `label` +@msg command {name} must be an object with an `id` and a `label` kommandot {name} måste vara ett objekt med `id` och `label` +@msg missing field `{key}` fältet `{key}` saknas +@msg duplicate field `{key}` fältet `{key}` förekommer två gånger @msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` @msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..ea815092f7 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2006,6 +2006,38 @@ fn plugin_limit_errors_are_translated() { } } +/// A manifest whose `commands` value has the wrong shape is refused with an error that every +/// language of [`COMPLETE_MESSAGES`] translates whole, the command's name and the place in the +/// manifest included. +#[test] +fn wrongly_shaped_plugin_commands_errors_are_translated() { + let many: Vec<String> = (0..=32).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect(); + let commands = [ + r#"{"id":"x"}"#.to_string(), + r#""x""#.to_string(), + "[1]".to_string(), + r#"[{"id":1,"label":"A"}]"#.to_string(), + r#"[{"id":"x","label":true}]"#.to_string(), + r#"[{"label":"B"}]"#.to_string(), + r#"[{"id":"x"}]"#.to_string(), + r#"[{"id":"x","id":"y","label":"A"}]"#.to_string(), + format!("[{}]", many.join(",")), + ]; + let english = ["must", "list", "missing", "field", "duplicate", "object", "line", "column", "each", "with", "invalid", "expected"]; + for value in &commands { + let manifest = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":{value}}}"#); + let e = vectorcraft_plugins::manifest::Manifest::parse(manifest.as_bytes()).unwrap_err().to_string(); + let numbers: Vec<&str> = e.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect(); + for code in COMPLETE_MESSAGES { + let shown = message(Lang::from_code(code).expect("registered"), &e); + // Backticked names stay in English; the words around them are translated. + let words: String = shown.split('`').step_by(2).collect(); + assert!(words.split(|c: char| !c.is_alphabetic()).all(|w| !english.contains(&w)), "{code}: {e} -> {shown}"); + assert!(numbers.iter().all(|n| shown.contains(n)), "{code}: {e} -> {shown}"); + } + } +} + /// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above /// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..7c0ca14583 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6077,6 +6077,12 @@ axis Y Axis Y ekseni @msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın @msg command {name}: {e} {name} komutu: {e} +@msg manifest: {e} bildirim: {e} +@msg manifest: {e} at line {line} column {column} bildirim: {e} (satır {line}, sütun {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`, her biri `id` ve `label` içeren bir komut listesi olmalıdır +@msg command {name} must be an object with an `id` and a `label` {name} komutu `id` ve `label` içeren bir nesne olmalıdır +@msg missing field `{key}` `{key}` alanı eksik +@msg duplicate field `{key}` `{key}` alanı yinelenmiş @msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..328ba0c7d1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5718,6 +5718,12 @@ axis Y Axis Вісь Y @msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери @msg command {name}: {e} команда {name}: {e} +@msg manifest: {e} маніфест: {e} +@msg manifest: {e} at line {line} column {column} маніфест: {e} (рядок {line}, стовпець {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` має бути списком команд, кожна з `id` і `label` +@msg command {name} must be an object with an `id` and a `label` команда {name} має бути об'єктом з `id` і `label` +@msg missing field `{key}` немає поля `{key}` +@msg duplicate field `{key}` поле `{key}` вказано двічі @msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..1cc66addad 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5719,6 +5719,12 @@ axis Y Axis Trục Y @msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái @msg command {name}: {e} lệnh {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (dòng {line}, cột {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` phải là một danh sách lệnh, mỗi lệnh có `id` và `label` +@msg command {name} must be an object with an `id` and a `label` lệnh {name} phải là một đối tượng có `id` và `label` +@msg missing field `{key}` thiếu trường `{key}` +@msg duplicate field `{key}` trường `{key}` bị lặp @msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..66dfb5565d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5959,6 +5959,12 @@ axis Y Axis Y 轴 @msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID @msg command {name}: {e} 命令 {name}:{e} +@msg manifest: {e} 清单:{e} +@msg manifest: {e} at line {line} column {column} 清单:{e}(第 {line} 行,第 {column} 列) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` 必须是命令列表,每个命令都有 `id` 和 `label` +@msg command {name} must be an object with an `id` and a `label` 命令 {name} 必须是包含 `id` 和 `label` 的对象 +@msg missing field `{key}` 缺少字段 `{key}` +@msg duplicate field `{key}` 字段 `{key}` 重复 @msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` @msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..de9bd3d0ad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5756,6 +5756,12 @@ axis Y Axis Y 軸 @msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID @msg command {name}: {e} 指令 {name}:{e} +@msg manifest: {e} 資訊清單:{e} +@msg manifest: {e} at line {line} column {column} 資訊清單:{e}(第 {line} 行,第 {column} 欄) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` 必須是指令清單,每個指令都有 `id` 和 `label` +@msg command {name} must be an object with an `id` and a `label` 指令 {name} 必須是包含 `id` 和 `label` 的物件 +@msg missing field `{key}` 找不到欄位 `{key}` +@msg duplicate field `{key}` 欄位 `{key}` 重複 @msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` @msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 From a755082607c4507ff39c209749bf427e84c3ab97 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:06:52 -0800 Subject: [PATCH 076/126] MCP: an error from Export for Screens into a preset folder outside the read roots names that folder by its name document.exportForScreens without a folder writes into the folder its exportPreset keeps. When that folder is outside the client's read roots and the export fails, for example because the folder is also outside the write roots, the error now names the folder by its name, as the reply of a successful export leaves it out. A granted root in the same message stays whole, and a folder the client gives stays whole in the error. --- crates/engine/src/cmd/fileio/screens.rs | 62 ++++++++++++++++++- crates/engine/src/cmd/fileio/tests_screens.rs | 35 +++++++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 3974a574b1..bcc4bcb178 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -647,7 +647,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { // A folder the client gave, as against the one its `exportPreset` keeps. let given = p.get("folder").is_some(); let p = with_preset(s, p)?; - let mut r = export(s, &p)?; + let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; + let mut r = export(s, &p).map_err(|e| match hidden { + Some(folder) => leave_out_folder_from_error(e, folder), + None => e, + })?; remember(s, &p); if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); @@ -655,6 +659,41 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { Ok(r) } +/// An error of an export into `folder`, a folder outside the read roots that the client did not +/// give: `folder` as the path the error is about names it by its own name +/// ([`super::access::shown_path`]), and the rest of the message stays. +fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { + let name = super::access::shown_path(folder); + match e { + EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, + e => e, + } +} + +/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start +/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or +/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message +/// names stays whole, even one inside `folder`. +fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(folder) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), @@ -1436,7 +1475,26 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, safe_file_name}; + use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + + /// A hidden export folder is named by its name where a message is about it, and a root that + /// starts with the same text, or a longer path, stays whole. + #[test] + fn a_hidden_folder_is_named_by_its_name_in_a_message() { + let f = "/work/out"; + let cases = [ + ( + "automation path rejected: outside the write root /work/out/in: /work/out", + "automation path rejected: outside the write root /work/out/in: out", + ), + ("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"), + ("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"), + ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), + ]; + for (msg, want) in cases { + assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + } + } /// Files given one name are numbered in turn, each numbering going on from the last one (so /// thousands of them don't each try every number used before), and a file asking again for diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index b3e0642245..86ee164998 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -700,6 +700,41 @@ fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() { assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); } +/// An export into the folder an export preset keeps, refused because that folder is outside the +/// client's read and write roots: the error names the folder by its name only. A folder the client +/// gives stays whole in the error. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-refused"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string(); + + let e = run(json!({"exportPreset": "Kept"})); + assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}"); + let e = run(json!({"exportPreset": "Kept", "folder": folder})); + assert!(e.contains(&folder), "{e}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // A preset folder that holds the write root: the root stays whole in the error. + let parent = tmp.0.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": parent}); + s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); + let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); + let root = inside.display().to_string(); + let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); + assert!(e.contains(&format!("root {root}: {name}")), "{e}"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/docs/mcp.md b/docs/mcp.md index 5f02c87f2e..52fd2247c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,7 +128,8 @@ the suite) keep the agent to the folders you give it: a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` - of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder. + of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error + for that export names the folder by its name alone. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56b960dedb..680469b7fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,7 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. + - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 5be501e47f10cec005566eab257c239bd5181e04 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:09:14 -0800 Subject: [PATCH 077/126] DXF: an open path's fill outline is closed, as the fill is A filled open path wrote its outline as an open polyline, so with fill hatches left out (and in R12, and in CNC / CAM) the area lost its closing edge. Fill outlines now close each open subpath that can enclose an area; strokes of the same path stay open. --- crates/cad/src/scene.rs | 31 +++++++++++++++++++++++++++++-- crates/cad/src/tests.rs | 24 ++++++++++++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 +- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 300407eed6..7c5d8b6755 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -342,6 +342,32 @@ fn subpaths(bp: &BezPath) -> Vec<Sub> { out } +/// `bp` with each open subpath that can enclose an area closed, as a fill closes it: two or more +/// segments, or one curve. A single straight segment stays open. +fn closed_fill(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let (mut segs, mut curved) = (0usize, false); + let close = |out: &mut BezPath, segs: usize, curved: bool| { + if segs >= 2 || (segs == 1 && curved) { + out.close_path(); + } + }; + for el in bp.elements() { + match *el { + PathEl::MoveTo(_) => { + close(&mut out, segs, curved); + (segs, curved) = (0, false); + } + PathEl::ClosePath => (segs, curved) = (0, false), + PathEl::LineTo(_) => segs += 1, + PathEl::QuadTo(..) | PathEl::CurveTo(..) => (segs, curved) = (segs + 1, true), + } + out.push(*el); + } + close(&mut out, segs, curved); + out +} + /// The most pixels of an image written (it is held decoded, as RGBA and encoded at once). const MAX_IMAGE_PIXELS: u64 = 64 << 20; @@ -887,7 +913,7 @@ impl<'a> Scene<'a> { fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { if !self.o.version.handles() { self.warn(NO_FILLS); - self.lines(bp, style, 0.0); + self.lines(&closed_fill(bp), style, 0.0); return; } let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1; @@ -901,7 +927,8 @@ impl<'a> Scene<'a> { if loops.is_empty() { return; } - self.lines(outline, style, 0.0); + // The outlines close as the fill does, so an open path's fill keeps its closing edge. + self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { self.warn(HATCHES_OFF); return; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 2ffa594f44..9d2a519e1d 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -925,6 +925,30 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); } +#[test] +fn an_open_path_s_fill_outline_keeps_its_closing_edge() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((90.0, 10.0)); + bp.line_to((50.0, 80.0)); + let open = PathData::from_bezpath(&bp); + let d = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0)]); + let closed = |e: &Pairs| get(e, 70).and_then(|v| v.trim().parse::<i64>().ok()).is_some_and(|v| v & 1 == 1); + for include_hatches in [false, true] { + let out = export(&d, &DxfOptions { include_hatches, ..opts(&d) }).unwrap(); + let e: Vec<Pairs> = entities(&out).into_iter().filter(|x| kind(x) == "LWPOLYLINE").collect(); + assert_eq!(e.len(), 2, "the fill's outline and the stroke: {:?}", kinds(&out)); + assert!(closed(&e[0]), "the fill's outline closes as the fill does (hatches {include_hatches})"); + assert!(!closed(&e[1]), "the stroke stays open (hatches {include_hatches})"); + } + // R12 writes the outline alone, closed too. + let fill_only = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = export(&fill_only, &DxfOptions { version: DxfVersion::R12, ..opts(&fill_only) }).unwrap(); + let e = entities(&r12); + assert_eq!(kind(&e[0]), "POLYLINE", "{:?}", kinds(&r12)); + assert!(closed(&e[0]), "R12's outline closes as the fill does"); +} + #[test] fn images_can_be_left_out_with_their_files() { let d = image_and_fill_doc(Paint::None); diff --git a/docs/mcp.md b/docs/mcp.md index ac3e1feec1..381c177075 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5862,7 +5862,7 @@ DXF layer (hidden layers switched off, locked ones locked, non-printing ones not straight paths become polylines (an open path of one straight segment becomes a line, except for a stroke weight before 2000), curved ones cubic splines through every anchor, fills their outlines followed by solid hatches (laser and cutter software reads the outlines and skips hatches; R12 has no hatches: the outlines -alone), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked +alone; an open path's fill outline is closed, as the fill is), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). `includeImages: false` leaves placed images out, with no image entity, no linked file and no decoding of their pixels, and `includeHatches: false` writes each filled area as its outlines alone, without its solid hatch (both default to `true`; these two diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4903c82f91..0b3c7b03f8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1501,7 +1501,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes (`fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). + - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes. An open path's fill outline is closed, as the fill is, in every version (`an_open_path_s_fill_outline_keeps_its_closing_edge`, `fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From bcc99014197a270cc1cb671fcf0520b1016e6d6e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:09:58 -0800 Subject: [PATCH 078/126] Brushes: drag a brush onto the canvas to place a copy of its art A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, centered where it is dropped (brush.placeArt). Dropped on a path, a brush is still applied to it. A pattern brush is placed as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. Calligraphic and bristle brushes have no art and are refused. A symbol instance in the art is measured by its symbol's art. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/brushsym.rs | 13 +- crates/engine/src/cmd/brushsym/brushes.rs | 87 ++++++++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/brushsym.rs | 112 ++++++++++++++++++ crates/ui-egui/src/canvas/context.rs | 19 ++- crates/ui-egui/src/canvas/tests.rs | 43 +++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/brushes.rs | 3 +- crates/ui-egui/src/widgets/drag.rs | 3 +- docs/mcp.md | 9 ++ docs/target-app-parity.md | 1 + 37 files changed, 339 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index b4b231a22b..a44b532028 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -33,8 +33,8 @@ mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; use brushes::{ - brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, - brush_selection_art, brush_set_current, brush_unused, expand_brush, + brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art, + brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; pub(crate) use dynamic::{edit_part, part_only, selected_part}; use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; @@ -67,6 +67,15 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, brush_selection_art ), + cmd!( + "brush.placeArt", + "Place Brush Art", + [], + None, + "{name, x?, y?} place a copy of the art a scatter, art or pattern brush is made of, centered on (x, y) (default: the center of the first artboard), and select it, as dragging the brush from the Brushes panel onto an empty part of the canvas does; a pattern brush places its tiles left to right (side, outer corner, inner corner, start, end; the ones it has art for) 10 pt apart, each in a group named Side Tile, Outer Corner Tile, Inner Corner Tile, Start Tile or End Tile, with an unpainted rectangle at the back of a tile that has a repeat frame marking that frame; a calligraphic or bristle brush is refused → {id}", + has_doc, + brush_place_art + ), cmd!( "brush.apply", "Apply Brush", diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index cf5dedb6bd..4d0cb028bc 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -483,6 +483,93 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "name": out })) } +/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it +/// places them. +const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"]; + +/// The space between the pattern brush tiles `brush.placeArt` places, in points. +const TILE_GAP: f64 = 10.0; + +/// The message for a brush that is not made of art. +fn no_art(name: &str) -> String { + format!("`{name}` has no art (only scatter, art and pattern brushes do)") +} + +/// Tile `i` of `p` ([`TILE_NAMES`] order): its art and its repeat frame, when it has art. +fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_geom::Rect>)> { + match i { + 0 => Some((&p.side, p.side_frame)), + 1 => p.outer_corner.as_ref().map(|n| (n, p.outer_corner_frame)), + 2 => p.inner_corner.as_ref().map(|n| (n, p.inner_corner_frame)), + 3 => p.start.as_ref().map(|n| (n, p.start_frame)), + _ => p.end.as_ref().map(|n| (n, p.end_frame)), + } +} + +/// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art +/// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile +/// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame +/// marking that frame. `None` for a brush that is not made of art. Symbol instances in the art are +/// measured by their symbols' art in `symbols`. +fn placed_art(b: &Brush, symbols: &vectorcraft_doc::SymbolBoxes) -> Option<Node> { + match &b.kind { + BrushKind::Scatter(sc) => Some(sc.art.clone()), + BrushKind::Art(a) => Some(a.art.clone()), + BrushKind::Pattern(pb) => { + let mut tiles = vec![]; + let mut x = 0.0; + for (i, tile_name) in TILE_NAMES.iter().enumerate() { + let Some((art, frame)) = tile(pb, i) else { continue }; + let mut children = vec![]; + if let Some(f) = frame { + let marker = Node::path( + NodeId(0), + vectorcraft_geom::shapes::rectangle(f), + vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0), + ); + children.push(Arc::new(marker)); + } + children.push(Arc::new(art.clone())); + let mut g = Node::group(NodeId(0), children); + g.name = Some((*tile_name).to_string()); + let Some(r) = g.geometric_bounds_with(symbols) else { continue }; + // Each tile sits right of the one before it, centered on the line y = 0. + g.transform(vectorcraft_geom::Affine::translate((x - r.x0, -r.center().y)), false); + x += r.width() + TILE_GAP; + tiles.push(Arc::new(g)); + } + Some(Node::group(NodeId(0), tiles)) + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => None, + } +} + +/// `brush.placeArt`: a copy of the art brush `name` is made of, centered on (`x`, `y`) (default: the +/// center of the first artboard) in the layer new art goes into, and selected, as one undo step. +/// Scatter and art brushes place their art; pattern brushes place their tiles ([`placed_art`]). +pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "brush.placeArt"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let center = d.artboards.first().map(|a| a.rect.center()).unwrap_or(super::Point::ZERO); + let at = super::Point::new(super::number_or(p, "x", center.x, C)?, super::number_or(p, "y", center.y, C)?); + let b = brush::find(d, &name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let symbols = d.symbol_boxes(); + let mut art = placed_art(&b, &symbols).ok_or_else(|| bad(C, no_art(&name)))?; + if let Some(r) = art.geometric_bounds_with(&symbols) { + art.transform(vectorcraft_geom::Affine::translate(at - r.center()), false); + } + let parent = s.doc()?.target_parent()?; + let id = s.edit("Place Brush Art", |d, sel| { + let art = d.reid(&art); + let id = art.id; + d.insert(parent, usize::MAX, art)?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> { let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 51470e742d..ff1b1e4d3e 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -108,6 +108,7 @@ pub(super) const ALLOWED: &[&str] = &[ "brush.move", "brush.new", "brush.options", + "brush.placeArt", "brush.remove", "brush.selectionArt", "brush.setCurrent", diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index fd1ac70476..d678815715 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -253,6 +253,118 @@ fn selection_art_is_the_art_new_brush_takes() { assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step"); } +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn selected(s: &Session) -> Vec<NodeId> { + s.doc().unwrap().selection.objects.clone() +} + +/// The JSON of object `id`, as `brush.options` params take art. +fn art_json(s: &mut Session, id: NodeId) -> Value { + s.execute("brush.selectionArt", &json!({"ids": [id.0]})).unwrap()["art"].clone() +} + +/// `brush.placeArt` places a copy of an art or scatter brush's art centered where it is dropped +/// (by default on the first artboard's center), selected, in one undo step; brushes without art, +/// unknown brushes and a position that is not a number are refused. +#[test] +fn place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped() { + let mut s = session(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + let steps = undo_steps(&s); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Bar", "x": 100, "y": 80})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(selected(&s), vec![id]); + assert_ne!(id, r, "a copy, not the art the brush was made from"); + assert!(close(bounds(&s, id), Rect::new(80.0, 76.0, 120.0, 84.0), 1e-6), "{:?}", bounds(&s, id)); + // Without a position: the first artboard's center (the document is 400 × 300). + let scatter = NodeId(s.execute("brush.placeArt", &json!({"name": "Dots"})).unwrap()["id"].as_u64().unwrap()); + let c = bounds(&s, scatter).center(); + assert!((c.x - 200.0).abs() < 1e-6 && (c.y - 150.0).abs() < 1e-6, "{c:?}"); + let steps = undo_steps(&s); + for (p, msg) in [ + (json!({"name": "3 pt. Round"}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Bristle Round"}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Nope"}), "no brush named `Nope`"), + ] { + let e = s.execute("brush.placeArt", &p).unwrap_err(); + assert!(e.to_string().contains(msg), "{p}: {e}"); + } + assert!(s.execute("brush.placeArt", &json!({"name": "Bar", "x": "100"})).is_err()); + assert_eq!(undo_steps(&s), steps, "a refused call adds no undo step"); +} + +/// Brush art holding an instance of a symbol whose art lies away from the symbol's origin (as +/// imported symbols can) is centered by that art, not by the box an instance has without its +/// document. +#[test] +fn placed_art_with_a_symbol_instance_is_centered_by_the_symbols_art() { + let mut s = session(); + let r = rect(&mut s, 300.0, 10.0, 100.0, 40.0); + let art = node(&s, r); + s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Off".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((50.0, 50.0)); + d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap(); + let inst = inst.0; + s.execute("brush.new", &json!({"type": "art", "name": "Stamp", "ids": [inst]})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Stamp", "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let st = s.doc().unwrap(); + let b = st.doc.bounds_of(&[id], false).unwrap(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}"); + assert!((b.width() - 100.0).abs() < 1e-6, "{b:?}"); +} + +/// A pattern brush with side, outer corner and start tiles, the side tile with a repeat frame +/// wider than its art: → its name. +fn framed_pattern_brush(s: &mut Session) -> String { + let side = rect(s, 0.0, 0.0, 20.0, 10.0); + let corner = rect(s, 0.0, 40.0, 10.0, 10.0); + let start = rect(s, 0.0, 80.0, 6.0, 10.0); + let name = s.execute("brush.new", &json!({"type": "pattern", "name": "Rail", "ids": [side.0]})).unwrap()["name"].as_str().unwrap().to_string(); + let params = json!({ + "side": art_json(s, side), + "side_frame": Rect::new(-2.0, 0.0, 22.0, 10.0), + "outer_corner": art_json(s, corner), + "start": art_json(s, start), + }); + s.execute("brush.options", &json!({"name": name, "params": params})).unwrap(); + name +} + +/// `brush.placeArt` lays a pattern brush's tiles out left to right, each in a group named for its +/// tile, with an unpainted rectangle at the back of a tile with a repeat frame marking that frame. +#[test] +fn pattern_brushes_place_their_tiles_in_a_row() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let placed = node(&s, id); + let tiles: Vec<Node> = placed.children().unwrap().iter().map(|c| (**c).clone()).collect(); + let names: Vec<&str> = tiles.iter().map(|t| t.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Side Tile", "Outer Corner Tile", "Start Tile"]); + let boxes: Vec<Rect> = tiles.iter().map(|t| t.geometric_bounds().unwrap()).collect(); + assert!(boxes.windows(2).all(|w| (w[1].x0 - w[0].x1 - 10.0).abs() < 1e-6), "10 pt apart, left to right: {boxes:?}"); + // The side tile: the frame's rectangle, unpainted, behind the art. + let side = tiles[0].children().unwrap(); + assert_eq!(side.len(), 2); + let marker = &side[0]; + assert!(marker.appearance.items.iter().all(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.is_none(), + vectorcraft_doc::AppearanceItem::Stroke(st) => st.paint.is_none(), + })); + let frame = marker.geometric_bounds().unwrap(); + assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.height() - 10.0).abs() < 1e-6, "{frame:?}"); + assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle"); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index c5bbe90882..cd98a214b7 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -53,7 +53,10 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: /// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the /// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries /// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on -/// top of its appearance). A chip follows the pointer meanwhile. +/// top of its appearance), and a brush is applied to it (`brush.apply`, `brush.addFromLibrary` +/// for a brush library's). A Brushes panel scatter, art or pattern +/// brush dropped where there is no art places a copy of its art there (`brush.placeArt`). A chip +/// follows the pointer meanwhile. pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { crate::panels::swatches::drag_preview(app, ui.ctx()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; @@ -86,7 +89,19 @@ pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respons } return; } - let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; + let hit = hit_at(app, xf.to_doc(pos), xf.zoom); + // A scatter, art or pattern brush from the Brushes panel dropped where there is no art: a copy + // of its art, centered where it is dropped, to edit and Alt-drag back onto the brush. + if let (None, widgets::PanelDrag::Brush { name, def }) = (&hit, &*d) + && matches!(def["type"].as_str(), Some("scatter" | "art" | "pattern")) + { + let at = xf.to_doc(pos); + if let Err(e) = app.run("brush.placeArt", json!({"name": name, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } + let Some(hit) = hit else { return }; let Some(st) = app.session.active() else { return }; let (cmd, params) = match &*d { widgets::PanelDrag::Paint { params, .. } => { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4f962fdb5d..cfde5e78c8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -6980,6 +6980,49 @@ fn a_brush_dropped_on_a_path_is_applied_to_it() { assert_eq!(brush(&app).as_deref(), Some("Arrow")); } +#[test] +fn a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, name: &str, at: Point| { + let def = app.session.execute("brush.get", &json!({"name": name})).unwrap(); + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: name.into(), def }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + let undo = app.session.active().unwrap().history.undo.len(); + // A calligraphic brush has no art: nothing. + drop(&mut app, "3 pt. Round", Point::new(200.0, 250.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + // An art brush: a copy of its art, centered where it lands, selected. + drop(&mut app, "Arrow", Point::new(200.0, 250.0)); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Place Brush Art"); + let [placed] = st.selection.objects.as_slice() else { panic!("one object selected: {:?}", st.selection.objects) }; + let b = st.doc.node(*placed).unwrap().geometric_bounds().unwrap(); + assert!((b.center() - Point::new(200.0, 250.0)).hypot() < 0.1, "{b:?}"); + // A scatter brush and a pattern brush (its tiles, in one group) place their art too. + for (i, name) in ["Dots", "Chain"].into_iter().enumerate() { + let at = Point::new(80.0 + 200.0 * i as f64, 60.0); + drop(&mut app, name, at); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 2 + i, "{name}"); + let [placed] = st.selection.objects.as_slice() else { panic!("{name}: one object selected: {:?}", st.selection.objects) }; + let n = st.doc.node(*placed).unwrap(); + assert!((n.geometric_bounds().unwrap().center() - at).hypot() < 0.1, "{name}"); + if name == "Chain" { + assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Side Tile")); + } + } +} + #[test] fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..15cd68c631 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6364,6 +6364,8 @@ breakText Glyphs Глифи Delete the selected brushes? Да се изтрият избраните четки? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките? Remove Strokes Премахване на щрихи + Place Brush Art Поставяне на графика на четка +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` няма графика (имат я само разпръснатите и артистичните четки и четките с мостра) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменти за подреждане Arrange in Grid… Подреждане в мрежа… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..7f5fe8b469 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6414,6 +6414,8 @@ shape Open Otvoren Delete the selected brushes? Izbrisati odabrane četkice? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice? Remove Strokes Ukloni poteze + Place Brush Art Postavi crtež četkice +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nema crtež (imaju ga samo raspršene i umjetničke četkice i četkice uzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alati za raspoređivanje Arrange in Grid… Rasporedi u mrežu… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..16ec9e744b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6620,6 +6620,8 @@ breakText Glyphs Glifs Delete the selected brushes? Voleu suprimir els pinzells seleccionats? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells? Remove Strokes Elimina els traços + Place Brush Art Col·loca l’art del pinzell +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no té art (només en tenen els pinzells de dispersió, artístics i de motiu) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Eines d’organització Arrange in Grid… Organitza en quadrícula… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..3187e78ef8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6639,6 +6639,8 @@ breakText Glyphs Glyfy Delete the selected brushes? Odstranit vybrané štětce? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce? Remove Strokes Odebrat tahy + Place Brush Art Umístit kresbu štětce +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nemá kresbu (mají ji jen rozptylové, obrazové a vzorkové štětce) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Nástroje uspořádání Arrange in Grid… Uspořádat do mřížky… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..828371be22 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6378,6 +6378,8 @@ breakText Glyphs Glyffer Delete the selected brushes? Slet de valgte pensler? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne? Remove Strokes Fjern streger + Place Brush Art Placér penselgrafik +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (det har kun sprednings-, grafik- og mønsterpensler) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Arrangeringsværktøjer Arrange in Grid… Arrangér i gitter… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..b953b6a1b9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6226,6 +6226,8 @@ breakText Glyphs Glyphen Delete the selected brushes? Ausgewählte Pinsel löschen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen? Remove Strokes Konturen entfernen + Place Brush Art Pinselgrafik platzieren +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` hat keine Grafik (nur Spezial-, Bild- und Musterpinsel haben eine) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Anordnungswerkzeuge Arrange in Grid… In Raster anordnen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..4f334818ff 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6591,6 +6591,8 @@ breakText Glyphs Γλύφοι Delete the selected brushes? Να διαγραφούν τα επιλεγμένα πινέλα; Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα; Remove Strokes Αφαίρεση περιγραμμάτων + Place Brush Art Τοποθέτηση σχεδίου πινέλου +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` δεν έχει σχέδιο (έχουν μόνο τα πινέλα διασποράς, σχεδίου και μοτίβου) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Εργαλεία διάταξης Arrange in Grid… Διάταξη σε πλέγμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..febf7cc8d0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6582,6 +6582,8 @@ breakText Glyphs Glifos Delete the selected brushes? ¿Eliminar los pinceles seleccionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles? Remove Strokes Quitar trazos + Place Brush Art Colocar ilustración del pincel +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no tiene ilustración (solo la tienen los pinceles de dispersión, de arte y de motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Herramientas de organización Arrange in Grid… Organizar en cuadrícula… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..803ca30249 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6669,6 +6669,8 @@ breakText Glyphs Merkit Delete the selected brushes? Poistetaanko valitut siveltimet? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet? Remove Strokes Poista viivat + Place Brush Art Sijoita siveltimen kuvitus +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ei sisällä kuvitusta (kuvitus on vain sirote-, taide- ja kuviosiveltimissä) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Järjestelytyökalut Arrange in Grid… Järjestä ruudukkoon… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..fe7d76e980 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6634,6 +6634,8 @@ breakText Glyphs Glyphes Delete the selected brushes? Supprimer les formes sélectionnées ? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ? Remove Strokes Supprimer les contours + Place Brush Art Placer l’illustration de la forme +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` n’a pas d’illustration (seules les formes diffuses, artistiques et de motif en ont) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Outils de disposition Arrange in Grid… Disposer en grille… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..b570076889 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3762,6 +3762,8 @@ antiAlias Sharp शार्प Remove Selected Assets सिलेक्टेड परिसंपत्तियों को हटा दें Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें Remove Strokes स्ट्रोक हटाएँ + Place Brush Art ब्रश आर्टवर्क रखें +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` में कोई आर्टवर्क नहीं है (आर्टवर्क केवल स्कैटर, आर्ट और पैटर्न ब्रश में होता है) Remove the shortcut शॉर्टकट निकालें Remove Threading थ्रेडिंग निकालें Remove White Overprint व्हाइट ओवरप्रिंट निकालें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..bebf443bf6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6623,6 +6623,8 @@ breakText Glyphs Glifák Delete the selected brushes? Törlöd a kijelölt ecseteket? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket? Remove Strokes Körvonalak eltávolítása + Place Brush Art Ecsetgrafika elhelyezése +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ecsetnek nincs grafikája (csak a szóró-, a grafikus és a mintázatecseteknek van) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Elrendezőeszközök Arrange in Grid… Elrendezés rácsba… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..44f773f2f5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6378,6 +6378,8 @@ breakText Glyphs Glif Delete the selected brushes? Hapus kuas yang dipilih? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya? Remove Strokes Hapus Stroke + Place Brush Art Tempatkan karya seni kuas +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` tidak memiliki karya seni (hanya kuas sebar, kuas seni, dan kuas pola yang memilikinya) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alat penyusunan Arrange in Grid… Susun dalam grid… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..d9639172f0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6260,6 +6260,8 @@ breakText Glyphs Glifi Delete the selected brushes? Eliminare i pennelli selezionati? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli? Remove Strokes Rimuovi tracce + Place Brush Art Inserisci grafica del pennello +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` non ha una grafica (ce l’hanno solo i pennelli a dispersione, artistici e motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Strumenti di disposizione Arrange in Grid… Disponi in griglia… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..4d3aee979d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6325,6 +6325,8 @@ breakText Glyphs 字形 Delete the selected brushes? 選択したブラシを削除しますか? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか? Remove Strokes 線を削除 + Place Brush Art ブラシのアートを配置 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` にはアートがありません (アートを持つのは散布ブラシ、アートブラシ、パターンブラシだけです) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 配置ツール Arrange in Grid… グリッドに配置… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..9b2aaca44c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6665,6 +6665,8 @@ breakText Glyphs გლიფები Delete the selected brushes? წაიშალოს არჩეული ფუნჯები? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები? Remove Strokes კონტურების მოხსნა + Place Brush Art ფუნჯის ნახატის განთავსება +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ნახატი არ აქვს (ნახატი მხოლოდ გაფანტვის, მხატვრულ და პატერნის ფუნჯებს აქვს) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools განლაგების ხელსაწყოები Arrange in Grid… ბადეში განლაგება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..28dda4e7ec 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6221,6 +6221,8 @@ breakText Glyphs 글리프 Delete the selected brushes? 선택한 브러시를 삭제하시겠습니까? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까? Remove Strokes 선 제거 + Place Brush Art 브러시 아트 배치 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}`에는 아트가 없습니다 (흩뿌리기, 아트, 패턴 브러시에만 아트가 있습니다) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 배치 도구 Arrange in Grid… 격자로 배치… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..0a9b7f742c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6429,6 +6429,8 @@ breakText Glyphs Glyphs Delete the selected brushes? Geselecteerde kwasten verwijderen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen? Remove Strokes Contouren verwijderen + Place Brush Art Penseelillustratie plaatsen +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` heeft geen illustratie (alleen verspreidings-, art- en patroonpenselen hebben er een) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Schikgereedschappen Arrange in Grid… In raster schikken… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..b5f4f10241 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6495,6 +6495,8 @@ breakText Glyphs Glify Delete the selected brushes? Usunąć zaznaczone pędzle? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle? Remove Strokes Usuń obrysy + Place Brush Art Umieść kompozycję pędzla +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nie ma kompozycji (mają ją tylko pędzle rozproszone, artystyczne i wzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Narzędzia układania Arrange in Grid… Ułóż w siatce… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..d3d55ea66c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6557,6 +6557,8 @@ breakText Glyphs Glifos Delete the selected brushes? Excluir os pincéis selecionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis? Remove Strokes Remover Traçados + Place Brush Art Colocar Arte do Pincel +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` não tem arte (só os pincéis de dispersão, de arte e de padrão têm) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ferramentas de Organização Arrange in Grid… Organizar em Grade… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..ae39c26edf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6616,6 +6616,8 @@ breakText Glyphs Глифы Delete the selected brushes? Удалить выбранные кисти? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти? Remove Strokes Убрать обводки + Place Brush Art Поместить рисунок кисти +@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` нет рисунка (он есть только у кистей-распылений, художественных и узорных кистей) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменты размещения Arrange in Grid… Разместить по сетке… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..c0ee00d2c6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6687,6 +6687,8 @@ breakText Glyphs Glyfer Delete the selected brushes? Radera de valda penslarna? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna? Remove Strokes Ta bort konturer + Place Brush Art Placera penselgrafik +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (bara spridnings-, konst- och mönsterpenslar har grafik) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ordningsverktyg Arrange in Grid… Ordna i rutnät… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..cce5a4496d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6595,6 +6595,8 @@ breakText Glyphs Glifler Delete the selected brushes? Seçili fırçalar silinsin mi? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi? Remove Strokes Konturları Kaldır + Place Brush Art Fırça Çizimini Yerleştir +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` fırçasının çizimi yok (yalnızca serpme, resim ve desen fırçalarının çizimi vardır) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Düzenleme Araçları Arrange in Grid… Izgarada Düzenle… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..ab55cec91a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6240,6 +6240,8 @@ breakText Glyphs Гліфи Delete the selected brushes? Видалити вибрані пензлі? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі? Remove Strokes Забрати обведення + Place Brush Art Розмістити малюнок пензля +@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` немає малюнка (він є лише в пензлів-розпорошень, художніх і візерункових пензлів) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Інструменти розміщення Arrange in Grid… Розмістити сіткою… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..f8f36f9b7a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6241,6 +6241,8 @@ breakText Glyphs Glyph Delete the selected brushes? Xóa các cọ đã chọn? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ? Remove Strokes Gỡ nét + Place Brush Art Đặt đối tượng của cọ +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` không có đối tượng (chỉ cọ phân tán, cọ bản vẽ và cọ họa tiết mới có) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Công cụ sắp xếp Arrange in Grid… Sắp xếp theo lưới… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..fbb5fcb5eb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6480,6 +6480,8 @@ breakText Glyphs 字形 Delete the selected brushes? 删除选中的画笔吗? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗? Remove Strokes 移除描边 + Place Brush Art 置入画笔图稿 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 没有图稿 (只有散布画笔、艺术画笔和图案画笔有图稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 Arrange in Grid… 按网格排列… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..ddd1bcb1b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6278,6 +6278,8 @@ breakText Glyphs 字符 Delete the selected brushes? 要刪除選取的筆刷嗎? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎? Remove Strokes 移除筆畫 + Place Brush Art 置入筆刷圖稿 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 沒有圖稿 (只有散佈筆刷、藝術筆刷和圖樣筆刷有圖稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 Arrange in Grid… 依格狀排列… diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index a282ff9973..d3970497d4 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -2,7 +2,8 @@ //! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking //! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or //! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with, -//! for Delete Brush and Duplicate Brush. Brush libraries open in library panels +//! for Delete Brush and Duplicate Brush. A scatter, art or pattern brush dragged onto an empty part +//! of the canvas places a copy of its art there. Brush libraries open in library panels //! ([`BrushLibraries`]). use std::cell::RefCell; diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index a6789916c6..1564025a41 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -30,7 +30,8 @@ pub enum PanelDrag { /// `place`). LibrarySymbol { library: String, name: String }, /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is - /// dropped on takes it (`brush.apply`). + /// dropped on takes it (`brush.apply`); dropped where there is no art, a scatter, art or pattern + /// brush places a copy of its art there (`brush.placeArt`). Brush { name: String, def: serde_json::Value }, /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..4715fc0462 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2895,6 +2895,15 @@ Brushes panel's New Brush button, its menu's New Brush… and art dropped on the Brush Options for the new brush; for a scatter, art or pattern brush they preview the art from `brush.selectionArt`. Their OK runs `brush.new {type, name, params, ids?}`, and Cancel adds no brush. +`brush.placeArt {name, x?, y?}` places a copy of the art a scatter, art or pattern brush is made of, centered on +(`x`, `y`) (default: the center of the first artboard), and selects it, in one undo step. The app runs it when a +brush is dragged from the Brushes panel onto an empty part of the canvas; dropped on a path, a brush is applied to it +(`brush.apply`). A pattern brush places its tiles left to right, 10 pt apart, in the order side, outer corner, inner +corner, start, end (only the tiles it has art for), each in a group named Side Tile, Outer Corner Tile, Inner Corner +Tile, Start Tile or End Tile. A tile with a repeat frame has an unpainted rectangle at the back of its group that marks +the frame. A symbol instance in the art is measured by its symbol's art. A calligraphic or bristle brush is refused +with "`<name>` has no art (only scatter, art and pattern brushes do)". + `variable.bind`, `variable.unbind`, `variable.delete`, `dataset.set`, `dataset.update`, `dataset.delete`, `dataset.select`, `dataset.next` and `dataset.prev` add no undo step when they leave the document as it was, such as binding art to a variable it already has or applying the active data set again: the steps to redo stay, and a saved diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..5660e04c35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1337,6 +1337,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. + A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). From ca90946d8d56935a0e2eacf76562e827bf764011 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:46:54 -0800 Subject: [PATCH 079/126] Data Recovery: a folder error names the folder that is missing or is not a folder When a folder on the way to the Data Recovery folder was missing and couldn't be made, or was a file, the error named the area folder or the copy inside it, for example `<recovery>/1794385145-1: Not a directory (os error 20)`. Errors on the store's folders and files now name the first folder on the way, from the top, that is not a folder ("<path> is not a folder: <OS error>") or is missing ("couldn't make <path>: <OS error>" while the store makes it, "<path> is missing" otherwise). When every folder on the way is there, the error names the folder or file it happened on. When reading a folder reports it missing, listing checks the folders on the way, so a file there is an error and not an empty list. --- crates/engine/src/cmd/recovery.rs | 65 +++++++++++++++++++---- crates/engine/src/tests/recovery.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 3424da3ddd..136ba51ceb 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -274,7 +274,46 @@ impl FolderStore { builder.recursive(true); #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); - builder.create(dir).map_err(|e| format!("{}: {e}", dir.display())) + builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true)) + } + + /// The first folder on the way to `dir` (`dir` included), from the top down, that is not a + /// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of + /// them can't be checked. + fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> { + let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect(); + way.reverse(); + for p in way { + match std::fs::metadata(p) { + Ok(m) if m.is_dir() => {} + Ok(_) => return Some((p, true)), + Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)), + Err(_) => return None, + } + } + None + } + + /// The message for error `e` on folder `dir`, which was being made (`making`) or read. It + /// names the first folder on the way to `dir` that is not a folder, or is missing (when + /// `making`, the one that couldn't be made), and otherwise `dir`. + fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String { + match Self::in_the_way(dir) { + Some((p, true)) => format!("{} is not a folder: {e}", p.display()), + Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()), + Some((p, false)) if p != dir => format!("{} is missing", p.display()), + _ => format!("{}: {e}", dir.display()), + } + } + + /// The message for error `e` on file `f`. It names the first folder on the way to `f` that is + /// not a folder or is missing, and otherwise `f`. + fn file_error(f: &std::path::Path, e: &std::io::Error) -> String { + match f.parent().and_then(Self::in_the_way) { + Some((p, true)) => format!("{} is not a folder: {e}", p.display()), + Some((p, false)) => format!("{} is missing", p.display()), + None => format!("{}: {e}", f.display()), + } } /// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix) @@ -282,7 +321,7 @@ impl FolderStore { /// linked to a folder of its own in a shared recovery folder looked free, and claiming it /// wrote the copies through the link. fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> { - let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?; if !m.file_type().is_dir() { return Err(format!("{}: not a folder of its own", dir.display())); } @@ -294,11 +333,17 @@ impl FolderStore { } /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist. + /// A file on the way to `dir` is an error: Windows reports such a folder as missing. fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> { let entries = match std::fs::read_dir(dir) { Ok(e) => e, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), - Err(e) => return Err(format!("{}: {e}", dir.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return match Self::in_the_way(dir) { + Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())), + _ => Ok(vec![]), + }; + } + Err(e) => return Err(Self::folder_error(dir, &e, false)), }; Ok(entries .filter_map(|e| e.ok()) @@ -333,7 +378,7 @@ impl FolderStore { match e.kind() { std::io::ErrorKind::NotFound => Ok(Lock::Busy), std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy), - _ => Err(format!("{}: {e}", path.display())), + _ => Err(Self::file_error(path, &e)), } } @@ -345,7 +390,7 @@ impl FolderStore { Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), // The lock file was removed meanwhile, and locking it failed instead. Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy), - Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)), } } @@ -393,10 +438,10 @@ impl RecoveryStore for FolderStore { if let Some(dir) = f.parent() { Self::own_area(dir)?; } - if !std::fs::symlink_metadata(&f).map_err(|e| format!("{}: {e}", f.display()))?.file_type().is_file() { + if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() { return Err(format!("{}: not a plain file", f.display())); } - std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display())) + std::fs::read(&f).map_err(|e| Self::file_error(&f, &e)) } fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { let f = self.file(name)?; @@ -404,12 +449,12 @@ impl RecoveryStore for FolderStore { Self::create_area(dir)?; Self::own_area(dir)?; } - vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display())) + vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e)) } fn remove(&self, name: &str) -> std::result::Result<(), String> { let f = self.file(name)?; match std::fs::remove_file(&f) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())), + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Self::file_error(&f, &e)), _ => Ok(()), } } diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index f0b6a0708c..2b991e3320 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -483,6 +483,87 @@ fn the_folder_store_reads_no_linked_entries() { let _ = std::fs::remove_dir_all(&base); } +/// A file where a folder of the store's path should be: the error names that file, not the area +/// folder or the copy below it, for a write, a lock, a read, a listing, a removal and a recovery +/// save. +#[cfg(unix)] +#[test] +fn folder_errors_name_a_file_in_the_way() { + let base = std::env::temp_dir().join(format!("vc-recovery-file-in-way-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let file = base.join("file"); + std::fs::write(&file, b"").unwrap(); + let folder = file.join("recovery"); + let store = FolderStore::new(&folder); + let mut s = Session::new(); + s.prefs.recovery_folder = folder.to_string_lossy().to_string(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let errors = [ + ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err()), + ("lock", store.lock("1-2").err()), + ("read", store.read("1-1/Untitled-1-1.vectorcraft").err()), + ("list", store.list().err()), + ("remove", store.remove("1-1/Untitled-1-1.vectorcraft").err()), + ("save", s.execute("file.recovery.save", &json!({})).err().map(|e| e.to_string())), + ]; + let _ = std::fs::remove_dir_all(&base); + let named = format!("{} is not a folder: ", file.display()); + let wrong: Vec<_> = errors.iter().filter(|(_, e)| !e.as_deref().is_some_and(|e| e.contains(&named))).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// A folder of the store's path that is missing and can't be made: a write or a lock names it as +/// the folder it couldn't make, and a read names it as missing. An area folder that can't be made +/// in a store folder that is there is named the same way. +#[cfg(unix)] +#[test] +fn folder_errors_name_a_missing_folder_that_cannot_be_made() { + use std::os::unix::fs::PermissionsExt as _; + let base = std::env::temp_dir().join(format!("vc-recovery-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let closed = base.join("closed"); + std::fs::create_dir_all(&closed).unwrap(); + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o500)).unwrap(); + let gone = closed.join("gone"); + let store = FolderStore::new(gone.join("recovery")); + let unmade = format!("couldn't make {}: ", gone.display()); + let area = FolderStore::new(&closed).write("1-1/Untitled-1-1.vectorcraft", b"copy").err(); + let errors = [ + ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err(), unmade.clone()), + ("lock", store.lock("1-2").err(), unmade), + ("read", store.read("1-1/Untitled-1-1.vectorcraft").err(), format!("{} is missing", gone.display())), + ("area", area, format!("couldn't make {}: ", closed.join("1-1").display())), + ]; + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o700)).unwrap(); + let _ = std::fs::remove_dir_all(&base); + // An account that can write anywhere (root) makes the folders, so the test has nothing to check. + if errors[0].1.is_none() { + return; + } + let wrong: Vec<_> = errors.iter().filter(|(_, e, named)| !e.as_deref().is_some_and(|e| e.starts_with(named.as_str()))).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// When every folder on the way is there, an error names the folder it happened on. +#[cfg(unix)] +#[test] +fn folder_errors_name_the_folder_when_every_folder_is_there() { + use std::os::unix::fs::PermissionsExt as _; + let base = std::env::temp_dir().join(format!("vc-recovery-unreadable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let unreadable = base.join("unreadable"); + std::fs::create_dir_all(&unreadable).unwrap(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o300)).unwrap(); + let listed = FolderStore::new(&unreadable).list(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let _ = std::fs::remove_dir_all(&base); + // An account that can read any folder (root) lists it, so the test has nothing to check. + let Err(err) = listed else { return }; + assert!(err.starts_with(&format!("{}: ", unreadable.display())), "{err}"); +} + /// A store in memory that notes each removal of an area (its lock file and folder, then its empty /// folder) and whether the area was still held then. #[derive(Default)] From d69e9c10d7d21f4ade82955ef8c80643deb1fb42 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:11:17 -0800 Subject: [PATCH 080/126] Brushes: Alt-drag art onto a brush to replace its art Art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the Brushes panel becomes its art (brush.replaceArt). When strokes use the brush, the app checks the art first, then a dialog offers Apply to Strokes, which redraws them with the new art, Leave Strokes, which keeps the brush and its strokes as they were and adds "<name> copy" with the new art as the current brush, and Cancel. Each change is one undo step. A pattern brush takes back the tile groups brush.placeArt places, frames included, and takes other art as its side tile. Calligraphic and bristle brushes, an empty selection and art with no width are refused. Art Alt-dragged off the canvas now reaches the panels as the art the drag started with. Before, the panels got the ids of the copy the drag previews, which no longer exist once the release over a panel ends the move. brush.new now also measures a symbol instance in the selected art by its symbol's art when it checks that the art has width. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/brushsym.rs | 11 +- crates/engine/src/cmd/brushsym/brushes.rs | 143 +++++++++- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/brushsym.rs | 249 ++++++++++++++++++ crates/ui-egui/src/canvas/drag.rs | 8 +- crates/ui-egui/src/canvas/tests.rs | 30 +++ crates/ui-egui/src/dialogs.rs | 2 + .../ui-egui/src/dialogs/brush_replace_art.rs | 100 +++++++ .../src/dialogs/brush_replace_art/tests.rs | 133 ++++++++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/brushes.rs | 27 +- crates/ui-egui/src/panels/brushes/tests.rs | 48 ++++ docs/mcp.md | 15 ++ docs/target-app-parity.md | 1 + 40 files changed, 866 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/brush_replace_art.rs create mode 100644 crates/ui-egui/src/dialogs/brush_replace_art/tests.rs diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index a44b532028..59a54eca5f 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -34,7 +34,7 @@ mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art, - brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, + brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; pub(crate) use dynamic::{edit_part, part_only, selected_part}; use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; @@ -76,6 +76,15 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, brush_place_art ), + cmd!( + "brush.replaceArt", + "Replace Brush Art", + [], + None, + "{name, ids?|id?, applyToStrokes?: true} make the selected art (several objects become one group, as in brush.new) the art of a scatter, art or pattern brush, keeping its other options, as Alt-dragging (Option-dragging on macOS) art onto the brush in the Brushes panel does; a pattern brush takes a group named for one of its tiles, or a group of such groups (as brush.placeArt places them), tile by tile (of two named for one tile, the frontmost), each with the repeat frame an unpainted rectangle at its back marks (other tiles stay as they are), and other art as its side tile; when strokes use the brush and applyToStrokes is false, the brush and its strokes stay as they were and a copy of the brush with the new art, named `<name> copy`, goes after it and becomes the current brush; otherwise the strokes redraw with the new art; one undo step; refused for a calligraphic or bristle brush, with nothing selected, and for art (or a tile) with no width → {name: the brush that has the new art}", + has_doc, + brush_replace_art + ), cmd!( "brush.apply", "Apply Brush", diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 4d0cb028bc..1f63076cfd 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -239,7 +239,7 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> { && v.get(k).is_none() { let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; - if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + if !has_width(&art, &s.doc()?.doc.symbol_boxes()) { return Err(bad(C, "the selected art has no width")); } v[k] = serde_json::to_value(&art).unwrap_or_default(); @@ -249,6 +249,12 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "name": name })) } +/// Whether art `n` has width for a brush to be made of, with symbol instances measured by their +/// symbols' art in `symbols`. +fn has_width(n: &Node, symbols: &vectorcraft_doc::SymbolBoxes) -> bool { + n.geometric_bounds_with(symbols).is_some_and(|b| b.width() > 1e-6) +} + /// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); @@ -367,6 +373,12 @@ fn art_brushes(n: &Node, out: &mut Vec<String>) { pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> { let d = &s.doc()?.doc; let lib = brush::library(d); + let names = brush_unused_in(d, &lib); + Ok(json!({ "names": names })) +} + +/// The brushes of `lib` no stroke in `d` paints with, in library order ([`brush_unused`]). +fn brush_unused_in(d: &Document, lib: &[Brush]) -> Vec<String> { let mut todo = vec![]; let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); @@ -379,8 +391,7 @@ pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> { used.insert(name); } } - let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); - Ok(json!({ "names": names })) + lib.iter().map(|b| b.name.clone()).filter(|n| !used.contains(n)).collect() } pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { @@ -484,7 +495,7 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { } /// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it -/// places them. +/// places them; `brush.replaceArt` reads a group of that name as that tile. const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"]; /// The space between the pattern brush tiles `brush.placeArt` places, in points. @@ -506,6 +517,18 @@ fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_ } } +/// Gives tile `i` of `p` ([`TILE_NAMES`] order) the art `art` and the repeat frame `frame`. The tile +/// no longer comes from a pattern swatch, and a corner draws its own art. +fn set_tile(p: &mut brush::PatternBrush, i: usize, art: Node, frame: Option<vectorcraft_geom::Rect>) { + match i { + 0 => (p.side, p.side_frame, p.side_swatch) = (art, frame, None), + 1 => (p.outer_corner, p.outer_corner_frame, p.outer_corner_swatch, p.outer_source) = (Some(art), frame, None, None), + 2 => (p.inner_corner, p.inner_corner_frame, p.inner_corner_swatch, p.inner_source) = (Some(art), frame, None, None), + 3 => (p.start, p.start_frame, p.start_swatch) = (Some(art), frame, None), + _ => (p.end, p.end_frame, p.end_swatch) = (Some(art), frame, None), + } +} + /// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art /// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile /// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame @@ -570,6 +593,118 @@ pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "id": id.0 })) } +/// The repeat frame an unpainted rectangle at the back of a tile group marks ([`placed_art`]): the +/// rectangle's bounds, when `n` is a path of one closed subpath through the four corners of its +/// bounds, without curves, whose fills and strokes paint nothing and that has no other appearance. +fn frame_marker(n: &Node) -> Option<vectorcraft_geom::Rect> { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return None }; + let unpainted = n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none(), + AppearanceItem::Stroke(st) => st.paint.is_none(), + }); + let [sp] = path.subpaths.as_slice() else { return None }; + let r = n.geometric_bounds()?; + let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)]; + let at_corners = sp.anchors.len() == 4 + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p) + && corners.iter().all(|c| sp.anchors.iter().any(|a| (a.p.x, a.p.y) == *c)); + (unpainted && sp.closed && at_corners && r.width() > 1e-9 && r.height() > 1e-9).then_some(r) +} + +/// The tiles in `art` for a pattern brush, by tile ([`TILE_NAMES`] order), with their repeat frames: +/// a group named for a tile, as `brush.placeArt` places them, is the art of its tile (an unpainted +/// rectangle at its back marks the tile's frame, [`frame_marker`], and is left out of the art; a +/// group left with one member gives that member), and so is each member of a group whose members +/// are all such groups; any other art is the side tile. +fn tiles_of(art: Node) -> Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> { + let named = |c: &Node| matches!(c.kind, NodeKind::Group { clip: false, .. }) && c.name.as_deref().is_some_and(|n| TILE_NAMES.contains(&n)); + let tiles = match &art.kind { + _ if named(&art) => vec![Arc::new(art)], + NodeKind::Group { children, clip: false } if !children.is_empty() && children.iter().all(|c| named(c)) => children.clone(), + _ => return vec![(0, art, None)], + }; + let mut out: Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> = vec![]; + for t in tiles { + let Some(i) = t.name.as_deref().and_then(|n| TILE_NAMES.iter().position(|x| *x == n)) else { continue }; + let mut g = (*t).clone(); + let mut frame = None; + if let NodeKind::Group { children, .. } = &mut g.kind + && let Some(f) = children.first().and_then(|c| frame_marker(c)) + { + frame = Some(f); + children.remove(0); + } + g.name = None; + // The group `brush.placeArt` put around the tile's art: the art itself goes back, so a + // tile does not gain a group each time it goes out and back. + if let NodeKind::Group { children, clip: false } = &g.kind + && let [only] = children.as_slice() + && g == Node::group(g.id, children.clone()) + { + g = (**only).clone(); + } + // A tile named twice: the frontmost one counts. + out.retain(|(j, ..)| *j != i); + out.push((i, g, frame)); + } + out +} + +/// `brush.replaceArt`: the selected art (or `ids`/`id`; several objects become one group, as in +/// `brush.new`) becomes the art of scatter, art or pattern brush `name`, keeping its other options, +/// as one undo step. A pattern brush takes the tiles `brush.placeArt` places by their names +/// ([`tiles_of`]) and other art as its side tile. When strokes use the brush and `applyToStrokes` is +/// false, the brush and its strokes stay as they were and a copy of the brush with the new art is +/// added after it and becomes the current brush; otherwise the strokes redraw with the new art. +pub(super) fn brush_replace_art(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "brush.replaceArt"; + let name = name_param(p, C)?.to_string(); + let apply = super::switch_or(p, "applyToStrokes", true, C)?; + let mut lib = brush::library(&s.doc()?.doc); + let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if lib[i].art().is_empty() { + return Err(bad(C, no_art(&name))); + } + let art = selection_art(s, p)?.ok_or_else(|| bad(C, "nothing selected"))?; + let d = &s.doc()?.doc; + let symbols = d.symbol_boxes(); + let check = |n: &Node| if has_width(n, &symbols) { Ok(()) } else { Err(bad(C, "the selected art has no width")) }; + let mut b = lib[i].clone(); + match &mut b.kind { + BrushKind::Scatter(sc) => { + check(&art)?; + sc.art = art; + } + BrushKind::Art(a) => { + check(&art)?; + a.art = art; + } + BrushKind::Pattern(pb) => { + for (t, art, frame) in tiles_of(art) { + check(&art)?; + set_tile(pb, t, art, frame); + } + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return Err(bad(C, no_art(&name))), + } + let used = !brush_unused_in(d, &lib).contains(&name); + if apply || !used { + lib[i] = b; + save_library(s, "Replace Brush Art", lib, &[])?; + return Ok(json!({ "name": name })); + } + b.name = brush::unique_name(&lib, &format!("{} copy", name.trim())); + let copy = b.name.clone(); + lib.insert(i + 1, b); + s.edit("Replace Brush Art", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(copy)); + brush::store(d, &lib); + Ok(()) + })?; + Ok(json!({ "name": copy })) +} + pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> { let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index ff1b1e4d3e..71ab4f2957 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -110,6 +110,7 @@ pub(super) const ALLOWED: &[&str] = &[ "brush.options", "brush.placeArt", "brush.remove", + "brush.replaceArt", "brush.selectionArt", "brush.setCurrent", "brush.unused", diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index d678815715..1a1fcd87b5 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -365,6 +365,255 @@ fn pattern_brushes_place_their_tiles_in_a_row() { assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle"); } +/// `brush.replaceArt` reads the tiles `brush.placeArt` places back tile by tile, frames included: +/// every tile and the frame keep their sizes, and the frame keeps its place around the art. +#[test] +fn placed_pattern_tiles_go_back_into_the_brush_tile_by_tile() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let steps = undo_steps(&s); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [id.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let after = s.execute("brush.get", &json!({"name": name})).unwrap(); + let size = |v: &Value| { + let n: Node = serde_json::from_value(v.clone()).unwrap(); + let b = n.geometric_bounds().unwrap(); + (b.width(), b.height()) + }; + for key in ["side", "outer_corner", "start"] { + let (a, b) = (size(&before[key]), size(&after[key])); + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{key}: {a:?} → {b:?}"); + } + assert!(after.get("end").is_none_or(Value::is_null) && after.get("inner_corner").is_none_or(Value::is_null)); + let frame: Rect = serde_json::from_value(after["side_frame"].clone()).unwrap(); + let art: Node = serde_json::from_value(after["side"].clone()).unwrap(); + let art = art.geometric_bounds().unwrap(); + assert!((frame.width() - 24.0).abs() < 1e-6 && (art.x0 - frame.x0 - 2.0).abs() < 1e-6, "{frame:?} around {art:?}"); + assert!(after["outer_corner_frame"].is_null() || after.get("outer_corner_frame").is_none()); +} + +/// Art that is not the tiles `brush.placeArt` places becomes a pattern brush's side tile, and the +/// other tiles stay; one tile group alone replaces only that tile. +#[test] +fn other_art_replaces_the_side_tile_of_a_pattern_brush() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let r = rect(&mut s, 100.0, 100.0, 30.0, 12.0); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [r.0]})).unwrap(); + let after = s.execute("brush.get", &json!({"name": name})).unwrap(); + let side: Node = serde_json::from_value(after["side"].clone()).unwrap(); + assert!(close(side.geometric_bounds().unwrap(), Rect::new(100.0, 100.0, 130.0, 112.0), 1e-6)); + assert!(after.get("side_frame").is_none_or(Value::is_null), "the old frame goes with the old art"); + assert_eq!((&after["outer_corner"], &after["start"]), (&before["outer_corner"], &before["start"])); + // One placed tile, its group dragged back alone: only the start tile changes. + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()); + let start = node(&s, id).children().unwrap()[2].clone(); + assert_eq!(start.name.as_deref(), Some("Start Tile")); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [start.id.0]})).unwrap(); + let last = s.execute("brush.get", &json!({"name": name})).unwrap(); + assert_eq!((&last["side"], &last["outer_corner"]), (&after["side"], &after["outer_corner"])); + let art: Node = serde_json::from_value(last["start"].clone()).unwrap(); + assert!(close(art.geometric_bounds().unwrap(), start.geometric_bounds().unwrap(), 1e-6), "the start tile is the placed one"); + assert!(art.name.is_none(), "the tile's name stays on the canvas"); +} + +/// `brush.replaceArt` gives a brush new art in one undo step: strokes that use it redraw with it, +/// or with `applyToStrokes: false` keep the brush as it was while a copy with the new art becomes +/// the current brush. A brush no stroke uses changes in place either way. +#[test] +fn replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush() { + let mut s = session(); + let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + let path = line(&mut s); + s.execute("brush.apply", &json!({"name": "Bar", "ids": [path.0]})).unwrap(); + let thin = brushed_bounds(&s, path).height(); + // A path painted with a brush made from the taller art: what the stroke should draw. + let tall = rect(&mut s, 10.0, 220.0, 40.0, 20.0); + s.execute("brush.new", &json!({"type": "art", "name": "Tall", "ids": [tall.0]})).unwrap(); + let reference = line(&mut s); + s.execute("brush.apply", &json!({"name": "Tall", "ids": [reference.0]})).unwrap(); + let want = brushed_bounds(&s, reference).height(); + assert!(want > thin * 2.0, "{want} against {thin}"); + // Apply to Strokes: the stroke redraws with the taller art. + let steps = undo_steps(&s); + let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [tall.0]})).unwrap(); + assert_eq!(r["name"], "Bar"); + assert_eq!(undo_steps(&s), steps + 1); + assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((brushed_bounds(&s, path).height() - thin).abs() < 1e-9, "undo puts the old art back"); + s.execute("edit.redo", &json!({})).unwrap(); + // Leave Strokes: the stroke keeps Bar as it was; Bar copy has the new art, after Bar, current. + let short = rect(&mut s, 10.0, 260.0, 40.0, 4.0); + let bar = s.execute("brush.get", &json!({"name": "Bar"})).unwrap(); + let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [short.0], "applyToStrokes": false})).unwrap(); + assert_eq!(r["name"], "Bar copy"); + assert_eq!(brush_of(&s, path).as_deref(), Some("Bar")); + assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap(), bar); + assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6); + let copy: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Bar copy"})).unwrap()["art"].clone()).unwrap(); + assert!((copy.geometric_bounds().unwrap().height() - 4.0).abs() < 1e-6); + let list = s.execute("brush.list", &json!({})).unwrap(); + assert_eq!(list["current"], "Bar copy"); + let names: Vec<&str> = list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect(); + let at = names.iter().position(|n| *n == "Bar").unwrap(); + assert_eq!(names.get(at + 1), Some(&"Bar copy")); + // No stroke uses Bar copy: its art changes in place, with or without applying. + s.execute("brush.replaceArt", &json!({"name": "Bar copy", "ids": [tall.0], "applyToStrokes": false})).unwrap(); + assert!(s.execute("brush.get", &json!({"name": "Bar copy 2"})).is_err()); +} + +/// `brush.replaceArt` refuses a brush without art, an unknown brush, an empty selection, art with no +/// width and an `applyToStrokes` that is not a boolean, and changes nothing then. +#[test] +fn replace_art_refuses_what_it_cannot_take() { + let mut s = session(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + let flat = s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap(); + let lib = s.execute("brush.list", &json!({})).unwrap(); + let steps = undo_steps(&s); + for (p, msg) in [ + (json!({"name": "3 pt. Round", "ids": [r.0]}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Bristle Round", "ids": [r.0]}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Nope", "ids": [r.0]}), "no brush named `Nope`"), + (json!({"name": "Arrow", "ids": [flat]}), "the selected art has no width"), + (json!({"name": "Chain", "ids": [flat]}), "the selected art has no width"), + (json!({"name": "Arrow", "ids": [r.0], "applyToStrokes": "no"}), "`applyToStrokes` must be true or false"), + ] { + let e = s.execute("brush.replaceArt", &p).unwrap_err(); + assert!(e.to_string().contains(msg), "{p}: {e}"); + } + s.select(|_, sel| sel.clear()).unwrap(); + let e = s.execute("brush.replaceArt", &json!({"name": "Arrow"})).unwrap_err(); + assert!(e.to_string().contains("nothing selected"), "{e}"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("brush.list", &json!({})).unwrap(), lib); +} + +/// An instance of a symbol whose art has no width is art with no width for `brush.new` and +/// `brush.replaceArt`, although an instance's box without its document has width. +#[test] +fn a_symbol_whose_art_has_no_width_makes_no_brush_art() { + let mut s = session(); + let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap()); + let art = node(&s, flat); + s.execute("edit.clear", &json!({"ids": [flat.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Flat".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let inst = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Flat".into(), xf: vectorcraft_geom::Affine::IDENTITY })) + .unwrap(); + assert!(node(&s, inst).geometric_bounds().is_some_and(|b| b.width() > 1.0), "the box an instance has without its document"); + for (cmd, p) in [("brush.new", json!({"type": "art", "ids": [inst.0]})), ("brush.replaceArt", json!({"name": "Arrow", "ids": [inst.0]}))] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(e.to_string().contains("the selected art has no width"), "{cmd}: {e}"); + } +} + +/// `brush.replaceArt` checks only the art it is given: a tile the pattern brush already has that +/// has no width does not stop another tile from being replaced. +#[test] +fn replace_art_checks_only_the_tiles_it_is_given() { + let mut s = session(); + let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap()); + let end = art_json(&mut s, flat); + s.execute("brush.options", &json!({"name": "Chain", "params": {"end": end}})).unwrap(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + s.execute("brush.replaceArt", &json!({"name": "Chain", "ids": [r.0]})).unwrap(); + let side: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Chain"})).unwrap()["side"].clone()).unwrap(); + assert!(close(side.geometric_bounds().unwrap(), bounds(&s, r), 1e-6)); +} + +/// Tile `key` (`side`, `start`…) of pattern brush `name`. +fn tile_art(s: &mut Session, name: &str, key: &str) -> Node { + serde_json::from_value(s.execute("brush.get", &json!({"name": name})).unwrap()[key].clone()).unwrap() +} + +/// A pattern brush tile placed with `brush.placeArt` and given back with `brush.replaceArt` is the +/// art it was, not that art inside the tile's group, however many times it goes out and back. +#[test] +fn pattern_tiles_go_out_and_back_without_gaining_a_group() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let size = |n: &Node| { + let b = n.geometric_bounds().unwrap(); + (b.width(), b.height()) + }; + let first = tile_art(&mut s, &name, "side"); + assert!(matches!(first.kind, NodeKind::Path { .. }), "{:?}", first.kind); + for round in 1..=2 { + let id = s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [id]})).unwrap(); + let side = tile_art(&mut s, &name, "side"); + assert!(matches!(side.kind, NodeKind::Path { .. }), "round {round}: {:?}", side.kind); + let (a, b) = (size(&first), size(&side)); + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "round {round}: {a:?} → {b:?}"); + } +} + +/// A tile group whose art was deleted, leaving only the rectangle that marks its frame, is art with +/// no width: the brush keeps its tile. +#[test] +fn a_tile_group_holding_only_its_frame_is_refused() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()); + let side = node(&s, id).children().unwrap()[0].clone(); + assert_eq!(side.name.as_deref(), Some("Side Tile")); + let art = side.children().unwrap()[1].id; + s.execute("edit.clear", &json!({"ids": [art.0]})).unwrap(); + let steps = undo_steps(&s); + let e = s.execute("brush.replaceArt", &json!({"name": name, "ids": [side.id.0]})).unwrap_err(); + assert!(e.to_string().contains("the selected art has no width"), "{e}"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("brush.get", &json!({"name": name})).unwrap(), before); +} + +/// Two groups named for the same tile: the frontmost one is the tile's art. +#[test] +fn the_frontmost_group_named_for_a_tile_is_that_tile() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let back = rect(&mut s, 10.0, 10.0, 20.0, 10.0); + let front = rect(&mut s, 10.0, 40.0, 30.0, 10.0); + let (back, front) = (node(&s, back), node(&s, front)); + s.execute("edit.clear", &json!({"ids": [back.id.0, front.id.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + let tile = |d: &mut Document, n: Node| { + let mut g = Node::group(d.alloc_id(), vec![std::sync::Arc::new(n)]); + g.name = Some("Start Tile".into()); + std::sync::Arc::new(g) + }; + let tiles = vec![tile(d, back), tile(d, front)]; + let both = Node::group(d.alloc_id(), tiles); + let both_id = both.id; + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, both).unwrap(); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [both_id.0]})).unwrap(); + let start = tile_art(&mut s, &name, "start"); + assert!((start.geometric_bounds().unwrap().width() - 30.0).abs() < 1e-6, "{:?}", start.geometric_bounds()); +} + +/// Type is art for `brush.replaceArt` as it is for `brush.new`: taken as it is. +#[test] +fn replace_art_takes_type_as_brush_new_does() { + let mut s = session(); + let t = s.execute("text.create", &json!({"x": 20, "y": 60, "text": "Hi", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let want = art_json(&mut s, NodeId(t)); + s.execute("brush.replaceArt", &json!({"name": "Arrow", "ids": [t]})).unwrap(); + assert_eq!(s.execute("brush.get", &json!({"name": "Arrow"})).unwrap()["art"], want); + let made = s.execute("brush.new", &json!({"type": "art", "ids": [t]})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.execute("brush.get", &json!({"name": made})).unwrap()["art"], want); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 930daf8fd4..1a385d5642 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -293,9 +293,7 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, let tool = app.session.tool_id(); let Some(st) = app.session.active().filter(|_| matches!(tool, "selection" | "directSelection" | "groupSelection")) else { return false }; // The tool's interaction while it moves (or Alt-copies) the selection. - if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { - return false; - } + let Some(it) = st.interaction.as_ref().filter(|i| i.label == "Move" || i.label == "Copy") else { return false }; if tool != "selection" && st.selection.objects.iter().any(|id| st.selection.partial(*id).is_some() || st.selection.segments.contains_key(id)) { return false; } @@ -305,7 +303,9 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) { return false; } - let ids = st.selection.objects.clone(); + // The art the drag started with: the release rolls the move back to it, and an Alt-copy's + // copies, selected while it previews, go with that. + let ids = it.selection.objects.clone(); egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); true } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index cfde5e78c8..3e122e433b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -7206,6 +7206,36 @@ fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { assert_eq!(st.history.undo.len(), undo); } +/// Art Alt-dragged off the canvas (a copy while it moves there) reaches the panels as the art the +/// drag started with, which is what stays when the release over a panel takes the copy back. +#[test] +fn art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let button = |pos: Pos2, pressed: bool| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); + let st = app.session.active().unwrap(); + assert_eq!(st.interaction.as_ref().map(|i| i.label.as_str()), Some("Copy")); + assert!(!st.selection.objects.contains(&id), "the copy is selected while it moves"); + frame(&mut app, &ctx, vec![Event::PointerMoved(pos2(850.0, 100.0))]); + assert_eq!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); + frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *before, "the copy goes, the original stays"); + assert!(st.doc.node(id).is_some()); +} + /// #4233: the Direct and Group Selection tools hand art they move whole to the panels too (Asset /// Export, Graphic Styles…), as the Selection tool does; the move waits and nothing is recorded. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..f7a02b0e6a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,6 +18,7 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod brush_replace_art; pub mod bullets_numbering; pub mod char_style_options; pub mod color_balance; @@ -322,6 +323,7 @@ registry! { SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, Confirm: [confirm::KIND] => confirm::SPEC, BrushDelete: [brush_delete::KIND] => brush_delete::SPEC, + BrushReplaceArt: [brush_replace_art::KIND] => brush_replace_art::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, diff --git a/crates/ui-egui/src/dialogs/brush_replace_art.rs b/crates/ui-egui/src/dialogs/brush_replace_art.rs new file mode 100644 index 0000000000..cc1a08b251 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_replace_art.rs @@ -0,0 +1,100 @@ +//! The Brushes panel's question when art Alt-dragged (Option-dragged on macOS) onto a brush that +//! strokes use is to replace its art ([`ask`]): Apply to Strokes, Leave Strokes or Cancel. Apply to +//! Strokes runs `brush.replaceArt` and the strokes redraw with the new art; Leave Strokes runs it +//! with `applyToStrokes: false`, which keeps the brush and its strokes as they were and adds a copy +//! of the brush with the new art. +//! +//! Fields: `name` (the brush), `ids` (the art), `apply` (set by the button pressed) and `__doc` (the +//! uid of the document it asks about). + +use serde_json::{Value, json}; + +use super::{DialogAction, DialogSpec}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets; + +/// The dialog kind of the question. +pub const KIND: &str = "brushReplaceArt"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| { + tl!("Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art?") + .to_string() + }, + body_with_actions: Some(body), + confirm, + ok: None, + max_width: Some(540.0), + ..DialogSpec::FORM +}; + +/// The field that holds the uid of the document the question is about. +const DOC: &str = "__doc"; + +/// Replace the art of brush `name` with the objects `ids`: at once when no stroke uses the brush, +/// else after asking whether the strokes take the new art. Art the brush cannot take (art with no +/// width, say) is refused before anything asks, with the reason in the status bar. +pub fn replace(app: &mut VectorcraftApp, name: &str, ids: &[u64]) { + let params = json!({"name": name, "ids": ids}); + if super::brush_delete::used(app, &[name.to_string()]) != Some(true) { + if let Err(e) = app.run("brush.replaceArt", params) { + app.status(e); + } + return; + } + // A preview on a copy of the document checks the art without changing anything. + if !app.session.in_interaction() && app.session.begin_interaction("Replace Brush Art").is_ok() { + let checked = app.session.preview("brush.replaceArt", ¶ms); + // The interaction began above, on the active document: ending it puts that document back + // as it was, and can fail only when no document is open, when there is nothing to put back. + let _ = app.session.cancel_interaction(); + if let Err(e) = checked { + app.status(e.to_string()); + return; + } + } + ask(app, name, ids); +} + +/// Ask whether the strokes that use brush `name` take the art of the objects `ids`. +pub fn ask(app: &mut VectorcraftApp, name: &str, ids: &[u64]) { + let doc = app.session.active().map(|d| d.uid); + app.ui.dialog = Some(Dialog::new(KIND, json!({"name": name, "ids": ids, "apply": true, DOC: doc}))); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let mut action = DialogAction::None; + ui.horizontal(|ui| { + if widgets::primary_button_enabled(ui, tl!("Apply to Strokes"), true).clicked() { + d.fields.insert("apply".into(), json!(true)); + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Leave Strokes")).clicked() { + d.fields.insert("apply".into(), json!(false)); + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Cancel")).clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +/// Closes before running. When another document has become the active one, it closes as Cancel does +/// and changes no brush. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { + app.ui.dialog = None; + if d.fields.get(DOC).and_then(Value::as_u64) != app.session.active().map(|doc| doc.uid) { + return Ok(json!({})); + } + let params = json!({ + "name": d.fields.get("name").cloned().unwrap_or(Value::Null), + "ids": d.fields.get("ids").cloned().unwrap_or_else(|| json!([])), + "applyToStrokes": d.bool("apply"), + }); + app.run("brush.replaceArt", params) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs b/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs new file mode 100644 index 0000000000..f42b7805c2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs @@ -0,0 +1,133 @@ +use egui::{Pos2, Rect, pos2, vec2}; +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::*; + +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, time: f64) -> egui::FullOutput { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let input = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + out +} + +fn press(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) { + frame(ctx, app, vec![], time); + let out = frame(ctx, app, vec![], time + 0.1); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("dialog accessibility tree").nodes; + let button = nodes.iter().find(|(_, node)| node.label().map(str::trim) == Some(label)); + let bounds = button.and_then(|(_, node)| node.bounds()).expect("visible button"); + let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], time + 0.2); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> u64 { + app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() +} + +fn art_height(app: &mut VectorcraftApp, name: &str) -> f64 { + let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()["art"].clone()).unwrap(); + art.geometric_bounds().unwrap().height() +} + +/// An art brush "Bar" (40 × 8 pt) painting a path, and a 40 × 20 pt rectangle: → (path, rectangle). +fn used_brush(app: &mut VectorcraftApp) -> (NodeId, u64) { + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bar = rect(app, 10.0, 10.0, 40.0, 8.0); + app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap(); + let path = NodeId(rect(app, 100.0, 100.0, 80.0, 40.0)); + app.run("brush.apply", json!({"name": "Bar", "ids": [path.0]})).unwrap(); + (path, rect(app, 10.0, 40.0, 40.0, 20.0)) +} + +#[test] +fn an_unused_brush_takes_the_art_without_asking() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bar = rect(&mut app, 10.0, 10.0, 40.0, 8.0); + app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap(); + let tall = rect(&mut app, 10.0, 40.0, 40.0, 20.0); + replace(&mut app, "Bar", &[tall]); + assert!(app.ui.dialog.is_none()); + assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6); +} + +#[test] +fn apply_to_strokes_gives_the_brush_the_new_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (path, tall) = used_brush(&mut app); + replace(&mut app, "Bar", &[tall]); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND), "strokes use Bar: it asks"); + let ctx = context(); + let before = app.session.active().unwrap().history.undo.len(); + press(&ctx, &mut app, "Apply to Strokes", 0.0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6); + let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap(); + assert_eq!(stroke.brush.as_deref(), Some("Bar")); +} + +#[test] +fn leave_strokes_adds_a_copy_and_cancel_changes_nothing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (path, tall) = used_brush(&mut app); + let ctx = context(); + let before = app.session.active().unwrap().history.undo.len(); + replace(&mut app, "Bar", &[tall]); + press(&ctx, &mut app, "Cancel", 0.0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), before); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6); + replace(&mut app, "Bar", &[tall]); + press(&ctx, &mut app, "Leave Strokes", 1.0); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6, "Bar stays as it was"); + assert!((art_height(&mut app, "Bar copy") - 20.0).abs() < 1e-6); + let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap(); + assert_eq!(stroke.brush.as_deref(), Some("Bar")); +} + +/// Art a used brush cannot take is refused before the question: no dialog, the reason in the status +/// bar, and the document and its undo history as they were. +#[test] +fn art_with_no_width_is_refused_before_it_asks() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let _ = used_brush(&mut app); + let flat = app.run("shape.line", json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap(); + let st = app.session.active().unwrap(); + let (doc, steps) = (st.doc.clone(), st.history.undo.len()); + replace(&mut app, "Bar", &[flat]); + assert!(app.ui.dialog.is_none(), "nothing asks"); + assert!(app.ui.status.contains("the selected art has no width"), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *doc); + assert_eq!(st.history.undo.len(), steps); +} + +#[test] +fn a_button_changes_no_brush_after_another_document_became_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (_, tall) = used_brush(&mut app); + let ctx = context(); + replace(&mut app, "Bar", &[tall]); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + press(&ctx, &mut app, "Apply to Strokes", 0.0); + assert!(app.ui.dialog.is_none()); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 15cd68c631..5ff0ae0dd4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6365,6 +6365,10 @@ breakText Glyphs Глифи Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките? Remove Strokes Премахване на щрихи Place Brush Art Поставяне на графика на четка + Replace Brush Art Замяна на графика на четка + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Щрихи използват тази четка. Да се приложи ли новата графика към тези щрихи, или те да останат както са и да се добави копие на четката с новата графика? + Apply to Strokes Прилагане към щрихите + Leave Strokes Оставяне на щрихите @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` няма графика (имат я само разпръснатите и артистичните четки и четките с мостра) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменти за подреждане diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 7f5fe8b469..959108aceb 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6415,6 +6415,10 @@ shape Open Otvoren Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice? Remove Strokes Ukloni poteze Place Brush Art Postavi crtež četkice + Replace Brush Art Zamijeni crtež četkice + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Potezi koriste ovu četkicu. Primijeniti novi crtež na te poteze ili ih ostaviti kakvi jesu i dodati kopiju četkice s novim crtežom? + Apply to Strokes Primijeni na poteze + Leave Strokes Ostavi poteze @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nema crtež (imaju ga samo raspršene i umjetničke četkice i četkice uzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alati za raspoređivanje diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 16ec9e744b..e2454aad60 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6621,6 +6621,10 @@ breakText Glyphs Glifs Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells? Remove Strokes Elimina els traços Place Brush Art Col·loca l’art del pinzell + Replace Brush Art Substitueix l’art del pinzell + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Hi ha traços que fan servir aquest pinzell. Voleu aplicar l’art nou a aquests traços, o deixar-los com estan i afegir una còpia del pinzell amb l’art nou? + Apply to Strokes Aplica als traços + Leave Strokes Deixa els traços @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no té art (només en tenen els pinzells de dispersió, artístics i de motiu) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Eines d’organització diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3187e78ef8..aa81a51e93 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6640,6 +6640,10 @@ breakText Glyphs Glyfy Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce? Remove Strokes Odebrat tahy Place Brush Art Umístit kresbu štětce + Replace Brush Art Nahradit kresbu štětce + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Tahy používají tento štětec. Použít novou kresbu na tyto tahy, nebo je ponechat beze změny a přidat kopii štětce s novou kresbou? + Apply to Strokes Použít na tahy + Leave Strokes Ponechat tahy @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nemá kresbu (mají ji jen rozptylové, obrazové a vzorkové štětce) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Nástroje uspořádání diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 828371be22..f804d15c88 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6379,6 +6379,10 @@ breakText Glyphs Glyffer Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne? Remove Strokes Fjern streger Place Brush Art Placér penselgrafik + Replace Brush Art Erstat penselgrafik + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Streger bruger denne pensel. Anvend den nye grafik på stregerne, eller lad dem være, som de er, og tilføj en kopi af penslen med den nye grafik? + Apply to Strokes Anvend på streger + Leave Strokes Bevar streger @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (det har kun sprednings-, grafik- og mønsterpensler) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Arrangeringsværktøjer diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b953b6a1b9..d243fdfcb2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6227,6 +6227,10 @@ breakText Glyphs Glyphen Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen? Remove Strokes Konturen entfernen Place Brush Art Pinselgrafik platzieren + Replace Brush Art Pinselgrafik ersetzen + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturen verwenden diesen Pinsel. Die neue Grafik auf diese Konturen anwenden oder sie unverändert lassen und eine Kopie des Pinsels mit der neuen Grafik hinzufügen? + Apply to Strokes Auf Konturen anwenden + Leave Strokes Konturen beibehalten @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` hat keine Grafik (nur Spezial-, Bild- und Musterpinsel haben eine) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Anordnungswerkzeuge diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4f334818ff..684d6cc724 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6592,6 +6592,10 @@ breakText Glyphs Γλύφοι Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα; Remove Strokes Αφαίρεση περιγραμμάτων Place Brush Art Τοποθέτηση σχεδίου πινέλου + Replace Brush Art Αντικατάσταση σχεδίου πινέλου + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Περιγράμματα χρησιμοποιούν αυτό το πινέλο. Να εφαρμοστεί το νέο σχέδιο σε αυτά τα περιγράμματα ή να μείνουν ως έχουν και να προστεθεί αντίγραφο του πινέλου με το νέο σχέδιο; + Apply to Strokes Εφαρμογή στα περιγράμματα + Leave Strokes Διατήρηση περιγραμμάτων @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` δεν έχει σχέδιο (έχουν μόνο τα πινέλα διασποράς, σχεδίου και μοτίβου) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Εργαλεία διάταξης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index febf7cc8d0..e39dac9478 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6583,6 +6583,10 @@ breakText Glyphs Glifos Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles? Remove Strokes Quitar trazos Place Brush Art Colocar ilustración del pincel + Replace Brush Art Reemplazar ilustración del pincel + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Algunos trazos usan este pincel. ¿Aplicar la nueva ilustración a esos trazos, o dejarlos como están y añadir una copia del pincel con la nueva ilustración? + Apply to Strokes Aplicar a los trazos + Leave Strokes Dejar los trazos @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no tiene ilustración (solo la tienen los pinceles de dispersión, de arte y de motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Herramientas de organización diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 803ca30249..4426ced671 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6670,6 +6670,10 @@ breakText Glyphs Merkit Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet? Remove Strokes Poista viivat Place Brush Art Sijoita siveltimen kuvitus + Replace Brush Art Korvaa siveltimen kuvitus + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Viivat käyttävät tätä sivellintä. Käytetäänkö uutta kuvitusta näissä viivoissa, vai jätetäänkö ne ennalleen ja lisätään siveltimestä kopio, jossa on uusi kuvitus? + Apply to Strokes Käytä viivoihin + Leave Strokes Jätä viivat ennalleen @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ei sisällä kuvitusta (kuvitus on vain sirote-, taide- ja kuviosiveltimissä) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Järjestelytyökalut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fe7d76e980..aaeba4b7b0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6635,6 +6635,10 @@ breakText Glyphs Glyphes Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ? Remove Strokes Supprimer les contours Place Brush Art Placer l’illustration de la forme + Replace Brush Art Remplacer l’illustration de la forme + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Des contours utilisent cette forme. Appliquer la nouvelle illustration à ces contours, ou les laisser tels quels et ajouter une copie de la forme avec la nouvelle illustration ? + Apply to Strokes Appliquer aux contours + Leave Strokes Conserver les contours @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` n’a pas d’illustration (seules les formes diffuses, artistiques et de motif en ont) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Outils de disposition diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b570076889..fe6ec770f2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3763,6 +3763,10 @@ antiAlias Sharp शार्प Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें Remove Strokes स्ट्रोक हटाएँ Place Brush Art ब्रश आर्टवर्क रखें + Replace Brush Art ब्रश आर्टवर्क बदलें + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? स्ट्रोक्स इस ब्रश का उपयोग कर रहे हैं। नया आर्टवर्क उन स्ट्रोक्स पर लागू करें, या उन्हें जैसा है वैसा छोड़कर नए आर्टवर्क वाले ब्रश की एक कॉपी जोड़ें? + Apply to Strokes स्ट्रोक्स पर लागू करें + Leave Strokes स्ट्रोक्स जैसे हैं वैसे छोड़ें @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` में कोई आर्टवर्क नहीं है (आर्टवर्क केवल स्कैटर, आर्ट और पैटर्न ब्रश में होता है) Remove the shortcut शॉर्टकट निकालें Remove Threading थ्रेडिंग निकालें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bebf443bf6..e9b9904d3d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6624,6 +6624,10 @@ breakText Glyphs Glifák Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket? Remove Strokes Körvonalak eltávolítása Place Brush Art Ecsetgrafika elhelyezése + Replace Brush Art Ecsetgrafika cseréje + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Körvonalak használják ezt az ecsetet. Alkalmazod az új grafikát ezekre a körvonalakra, vagy változatlanul hagyod őket, és hozzáadod az ecset egy másolatát az új grafikával? + Apply to Strokes Alkalmazás a körvonalakra + Leave Strokes Körvonalak megtartása @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ecsetnek nincs grafikája (csak a szóró-, a grafikus és a mintázatecseteknek van) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Elrendezőeszközök diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 44f773f2f5..ba7e79593f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6379,6 +6379,10 @@ breakText Glyphs Glif Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya? Remove Strokes Hapus Stroke Place Brush Art Tempatkan karya seni kuas + Replace Brush Art Ganti karya seni kuas + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Stroke menggunakan kuas ini. Terapkan karya seni baru ke stroke tersebut, atau biarkan apa adanya dan tambahkan salinan kuas dengan karya seni baru? + Apply to Strokes Terapkan ke Stroke + Leave Strokes Biarkan Stroke @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` tidak memiliki karya seni (hanya kuas sebar, kuas seni, dan kuas pola yang memilikinya) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alat penyusunan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d9639172f0..ee5ae0ccad 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6261,6 +6261,10 @@ breakText Glyphs Glifi Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli? Remove Strokes Rimuovi tracce Place Brush Art Inserisci grafica del pennello + Replace Brush Art Sostituisci grafica del pennello + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Alcune tracce usano questo pennello. Applicare la nuova grafica a queste tracce, o lasciarle come sono e aggiungere una copia del pennello con la nuova grafica? + Apply to Strokes Applica alle tracce + Leave Strokes Lascia le tracce @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` non ha una grafica (ce l’hanno solo i pennelli a dispersione, artistici e motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Strumenti di disposizione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4d3aee979d..091f577ec2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6326,6 +6326,10 @@ breakText Glyphs 字形 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか? Remove Strokes 線を削除 Place Brush Art ブラシのアートを配置 + Replace Brush Art ブラシのアートを置き換え + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? このブラシは線で使われています。新しいアートをそれらの線に適用しますか?それとも線をそのままにして、新しいアートを持つブラシのコピーを追加しますか? + Apply to Strokes 線に適用 + Leave Strokes 線を保持 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` にはアートがありません (アートを持つのは散布ブラシ、アートブラシ、パターンブラシだけです) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 配置ツール diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9b2aaca44c..3a2f58a6db 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6666,6 +6666,10 @@ breakText Glyphs გლიფები Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები? Remove Strokes კონტურების მოხსნა Place Brush Art ფუნჯის ნახატის განთავსება + Replace Brush Art ფუნჯის ნახატის ჩანაცვლება + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? კონტურები იყენებს ამ ფუნჯს. გამოყენებულ იქნას ახალი ნახატი ამ კონტურებზე, თუ დარჩეს ისინი როგორც არის და დაემატოს ფუნჯის ასლი ახალი ნახატით? + Apply to Strokes კონტურებზე გამოყენება + Leave Strokes კონტურების დატოვება @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ნახატი არ აქვს (ნახატი მხოლოდ გაფანტვის, მხატვრულ და პატერნის ფუნჯებს აქვს) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools განლაგების ხელსაწყოები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 28dda4e7ec..2d079caa7a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6222,6 +6222,10 @@ breakText Glyphs 글리프 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까? Remove Strokes 선 제거 Place Brush Art 브러시 아트 배치 + Replace Brush Art 브러시 아트 바꾸기 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 선에서 이 브러시를 사용합니다. 새 아트를 해당 선에 적용하시겠습니까, 아니면 선을 그대로 두고 새 아트로 브러시 사본을 추가하시겠습니까? + Apply to Strokes 선에 적용 + Leave Strokes 선 유지 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}`에는 아트가 없습니다 (흩뿌리기, 아트, 패턴 브러시에만 아트가 있습니다) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 배치 도구 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0a9b7f742c..150d0115a7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6430,6 +6430,10 @@ breakText Glyphs Glyphs Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen? Remove Strokes Contouren verwijderen Place Brush Art Penseelillustratie plaatsen + Replace Brush Art Penseelillustratie vervangen + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Contouren gebruiken dit penseel. De nieuwe illustratie op deze contouren toepassen, of ze laten zoals ze zijn en een kopie van het penseel met de nieuwe illustratie toevoegen? + Apply to Strokes Op contouren toepassen + Leave Strokes Contouren behouden @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` heeft geen illustratie (alleen verspreidings-, art- en patroonpenselen hebben er een) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Schikgereedschappen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b5f4f10241..1ffba19802 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6496,6 +6496,10 @@ breakText Glyphs Glify Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle? Remove Strokes Usuń obrysy Place Brush Art Umieść kompozycję pędzla + Replace Brush Art Zastąp kompozycję pędzla + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Obrysy używają tego pędzla. Zastosować nową kompozycję do tych obrysów, czy pozostawić je bez zmian i dodać kopię pędzla z nową kompozycją? + Apply to Strokes Zastosuj do obrysów + Leave Strokes Pozostaw obrysy @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nie ma kompozycji (mają ją tylko pędzle rozproszone, artystyczne i wzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Narzędzia układania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d3d55ea66c..420cb8fd35 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6558,6 +6558,10 @@ breakText Glyphs Glifos Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis? Remove Strokes Remover Traçados Place Brush Art Colocar Arte do Pincel + Replace Brush Art Substituir Arte do Pincel + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Traçados usam este pincel. Aplicar a nova arte a esses traçados, ou deixá-los como estão e adicionar uma cópia do pincel com a nova arte? + Apply to Strokes Aplicar aos Traçados + Leave Strokes Manter Traçados @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` não tem arte (só os pincéis de dispersão, de arte e de padrão têm) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ferramentas de Organização diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ae39c26edf..cf62f1059d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6617,6 +6617,10 @@ breakText Glyphs Глифы Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти? Remove Strokes Убрать обводки Place Brush Art Поместить рисунок кисти + Replace Brush Art Заменить рисунок кисти + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Обводки используют эту кисть. Применить новый рисунок к этим обводкам или оставить их как есть и добавить копию кисти с новым рисунком? + Apply to Strokes Применить к обводкам + Leave Strokes Оставить обводки @msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` нет рисунка (он есть только у кистей-распылений, художественных и узорных кистей) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменты размещения diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c0ee00d2c6..1609299f68 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6688,6 +6688,10 @@ breakText Glyphs Glyfer Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna? Remove Strokes Ta bort konturer Place Brush Art Placera penselgrafik + Replace Brush Art Ersätt penselgrafik + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturer använder den här penseln. Använd den nya grafiken på konturerna, eller låt dem vara som de är och lägg till en kopia av penseln med den nya grafiken? + Apply to Strokes Använd på konturer + Leave Strokes Behåll konturer @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (bara spridnings-, konst- och mönsterpenslar har grafik) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ordningsverktyg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cce5a4496d..5b2dd4d770 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6596,6 +6596,10 @@ breakText Glyphs Glifler Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi? Remove Strokes Konturları Kaldır Place Brush Art Fırça Çizimini Yerleştir + Replace Brush Art Fırça Çizimini Değiştir + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturlar bu fırçayı kullanıyor. Yeni çizim bu konturlara uygulansın mı, yoksa konturlar olduğu gibi bırakılıp fırçanın yeni çizimli bir kopyası mı eklensin? + Apply to Strokes Konturlara Uygula + Leave Strokes Konturları Bırak @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` fırçasının çizimi yok (yalnızca serpme, resim ve desen fırçalarının çizimi vardır) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Düzenleme Araçları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ab55cec91a..b1964576a2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6241,6 +6241,10 @@ breakText Glyphs Гліфи Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі? Remove Strokes Забрати обведення Place Brush Art Розмістити малюнок пензля + Replace Brush Art Замінити малюнок пензля + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Обведення використовують цей пензель. Застосувати новий малюнок до цих обведень чи залишити їх як є і додати копію пензля з новим малюнком? + Apply to Strokes Застосувати до обведень + Leave Strokes Залишити обведення @msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` немає малюнка (він є лише в пензлів-розпорошень, художніх і візерункових пензлів) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Інструменти розміщення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f8f36f9b7a..9d848e6bd3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6242,6 +6242,10 @@ breakText Glyphs Glyph Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ? Remove Strokes Gỡ nét Place Brush Art Đặt đối tượng của cọ + Replace Brush Art Thay đối tượng của cọ + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Có nét đang dùng cọ này. Áp dụng đối tượng mới cho các nét đó, hay giữ nguyên chúng và thêm một bản sao của cọ với đối tượng mới? + Apply to Strokes Áp dụng cho nét + Leave Strokes Giữ nguyên nét @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` không có đối tượng (chỉ cọ phân tán, cọ bản vẽ và cọ họa tiết mới có) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Công cụ sắp xếp diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fbb5fcb5eb..09f334a8e5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6481,6 +6481,10 @@ breakText Glyphs 字形 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗? Remove Strokes 移除描边 Place Brush Art 置入画笔图稿 + Replace Brush Art 替换画笔图稿 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 有描边使用了此画笔。要将新图稿应用到这些描边,还是保留描边不变并添加一个使用新图稿的画笔副本? + Apply to Strokes 应用于描边 + Leave Strokes 保留描边 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 没有图稿 (只有散布画笔、艺术画笔和图案画笔有图稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ddd1bcb1b2..596b86e4db 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6279,6 +6279,10 @@ breakText Glyphs 字符 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎? Remove Strokes 移除筆畫 Place Brush Art 置入筆刷圖稿 + Replace Brush Art 取代筆刷圖稿 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 有筆畫使用了此筆刷。要將新圖稿套用到這些筆畫,還是保留筆畫不變並加入一個使用新圖稿的筆刷複本? + Apply to Strokes 套用至筆畫 + Leave Strokes 保留筆畫 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 沒有圖稿 (只有散佈筆刷、藝術筆刷和圖樣筆刷有圖稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index d3970497d4..8f29c70475 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -3,7 +3,8 @@ //! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or //! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with, //! for Delete Brush and Duplicate Brush. A scatter, art or pattern brush dragged onto an empty part -//! of the canvas places a copy of its art there. Brush libraries open in library panels +//! of the canvas places a copy of its art there, and art from the canvas Alt-dragged (Option-dragged +//! on macOS) onto such a brush replaces its art. Brush libraries open in library panels //! ([`BrushLibraries`]). use std::cell::RefCell; @@ -270,6 +271,22 @@ fn reorder_drop( } } +/// Art dragged off the canvas and held over the tile or row `resp` of a scatter, art or pattern +/// brush `name` (of type `ty`) with Alt (Option on macOS) held: the tile is outlined, and released +/// there it sets `replaced` to the brush and the art's ids, whose art then replaces the brush's. +/// Without Alt, or on another type of brush, the drop is left to the list, which opens New Brush. +fn replace_drop(ui: &Ui, resp: &egui::Response, name: &str, ty: &str, replaced: &mut Option<(String, Vec<u64>)>) { + if !matches!(ty, "scatter" | "art" | "pattern") || !ui.input(|i| i.modifiers.alt) { + return; + } + let Some(drag) = resp.dnd_hover_payload::<PanelDrag>() else { return }; + let PanelDrag::Art(ids) = &*drag else { return }; + ui.painter().rect_stroke(resp.rect, 0.0, egui::Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + if resp.dnd_release_payload::<PanelDrag>().is_some() { + *replaced = Some((name.to_string(), ids.iter().map(|id| id.0).collect())); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (list, current) = brushes(app); @@ -285,6 +302,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut clicked: Option<(String, egui::Modifiers)> = None; let mut options: Option<String> = None; let mut moved: Option<Value> = None; + let mut replaced: Option<(String, Vec<u64>)> = None; let list_rect = widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); @@ -347,6 +365,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(ui.available_width(), 26.0), true); reorder_drop(ui, &resp, &list, (name, ty, i), true, &mut moved); + replace_drop(ui, &resp, name, ty, &mut replaced); pick(resp, name); } } else { @@ -354,6 +373,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(64.0, 28.0), false); reorder_drop(ui, &resp, &list, (name, ty, i), false, &mut moved); + replace_drop(ui, &resp, name, ty, &mut replaced); pick(resp, name); } }); @@ -362,6 +382,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.min_rect() }); + // Art Alt-dragged onto a scatter, art or pattern brush replaces its art, asking first when + // strokes use the brush. + if let Some((name, ids)) = replaced { + crate::dialogs::brush_replace_art::replace(app, &name, &ids); + } // Art dragged off the canvas and dropped on the list opens New Brush for that art, as the New // Brush button opens it for the selection; a brush library's brush dropped there is added. let zone = ui.interact(list_rect, ui.scope_id().with("brushes-drop"), Sense::hover()); diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index e4c5b9d764..fcab4373ce 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -461,6 +461,54 @@ fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() { assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}"); } +/// Art dragged off the canvas and released on a scatter, art or pattern brush with Alt held +/// replaces that brush's art (no stroke uses it, so nothing asks); without Alt, or on a +/// calligraphic brush, the list opens New Brush as before. +#[test] +fn art_alt_dropped_on_a_brush_with_art_replaces_its_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 12})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); + let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); + assert_eq!(shown(&brushes(&mut app).0, &[])[3], "Dots"); + let dots = app.run("brush.get", json!({"name": "Dots"})).unwrap(); + let drop = |app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64| { + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); + frame_with(&ctx, app, vec![egui::Event::PointerMoved(at)], modifiers, time, show); + frame_with(&ctx, app, vec![button(at, false)], modifiers, time + 0.1, show); + }; + // Without Alt: New Brush, and Dots stays as it was. + drop(&mut app, tiles[3].center(), egui::Modifiers::NONE, 1.0); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::new_brush::KIND)); + app.ui.dialog = None; + // Alt on a calligraphic or a bristle brush: New Brush too. + let list = brushes(&mut app).0; + let names = shown(&list, &[]); + let at = |name: &str| tiles[names.iter().position(|n| *n == name).unwrap()].center(); + for (i, name) in ["3 pt. Round", "Bristle Round"].into_iter().enumerate() { + let lib = app.run("brush.list", json!({})).unwrap(); + drop(&mut app, at(name), egui::Modifiers::ALT, 2.0 + i as f64); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::new_brush::KIND), "{name}"); + app.ui.dialog = None; + assert_eq!(app.run("brush.list", json!({})).unwrap(), lib, "{name}"); + } + assert_eq!(app.run("brush.get", json!({"name": "Dots"})).unwrap(), dots); + // Alt on Dots (scatter), Arrow (art) and Chain (pattern, its side tile): the rectangle is its + // art now, in one undo step each. + for (i, (name, key)) in [("Dots", "art"), ("Arrow", "art"), ("Chain", "side")].into_iter().enumerate() { + let steps = app.session.active().unwrap().history.undo.len(); + drop(&mut app, at(name), egui::Modifiers::ALT, 4.0 + i as f64); + assert!(app.ui.dialog.is_none(), "no stroke uses {name}: nothing asks"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "{name}"); + let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()[key].clone()).unwrap(); + let b = art.geometric_bounds().unwrap(); + assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 12.0).abs() < 1e-6, "{name}: {b:?}"); + } +} + /// A brush dragged onto the left half of another brush of its type goes before it, with a bar /// marking the slot while it is held there, as one undo step; dropped on a brush of another /// type, it stays where it is and the status bar says why. diff --git a/docs/mcp.md b/docs/mcp.md index 4715fc0462..7023855e21 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2904,6 +2904,21 @@ Tile, Start Tile or End Tile. A tile with a repeat frame has an unpainted rectan the frame. A symbol instance in the art is measured by its symbol's art. A calligraphic or bristle brush is refused with "`<name>` has no art (only scatter, art and pattern brushes do)". +`brush.replaceArt {name, ids?|id?, applyToStrokes?}` makes the selected art the brush's art and keeps its other +options; several objects become one group, as in `brush.new`. A pattern brush takes a group named for a tile, or a +group of such groups, tile by tile: an unpainted rectangle at the back of a tile group becomes that tile's repeat frame, +a tile group's art goes back without that group, and the tiles that are not given stay as they are. Of two groups named +for the same tile, the frontmost one counts. Other art becomes the side tile. When strokes use the brush and +`applyToStrokes` is `false`, the brush and its strokes stay as they were, and a copy of the brush with the new art, +named "`<name>` copy", goes after it and becomes the current brush. Otherwise, and for a brush no stroke uses, the brush +changes and its strokes redraw. It replies `{name}`, the brush that has the new art, and refuses a calligraphic or +bristle brush, an empty selection and art (or a tile it is given, such as a tile group left with only its frame's +rectangle) with no width. `brush.replaceArt` and `brush.new` measure a symbol instance in the art by its symbol's art. +In the app, art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the +Brushes panel runs it. When strokes use the brush, the app checks the art first and shows why in the status bar when +the brush cannot take it; otherwise a dialog (`brushReplaceArt`) asks: Apply to Strokes, Leave Strokes +(`applyToStrokes: false`) or Cancel. + `variable.bind`, `variable.unbind`, `variable.delete`, `dataset.set`, `dataset.update`, `dataset.delete`, `dataset.select`, `dataset.next` and `dataset.prev` add no undo step when they leave the document as it was, such as binding art to a variable it already has or applying the active data set again: the steps to redo stay, and a saved diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5660e04c35..fc34cfbdde 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1338,6 +1338,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). + Art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the Brushes panel becomes the brush's art, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.replaceArt`). When strokes use the brush, a dialog asks whether to change those strokes (Apply to Strokes) or leave them as they are (Leave Strokes), or to cancel, the choice the [help reference](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=263) (archived copy, p. 263) describes for a modified brush in use. Leave Strokes keeps the brush and its strokes as they were and adds a copy of the brush with the new art, named "`<name>` copy", which becomes the current brush. That copy is VectorCraft's own rule. A pattern brush takes back the tile groups `brush.placeArt` places, frames included, and takes other art as its side tile, which is also VectorCraft's own rule. Evidence: `tests::brushsym::replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush`, `tests::brushsym::placed_pattern_tiles_go_back_into_the_brush_tile_by_tile`, `tests::brushsym::other_art_replaces_the_side_tile_of_a_pattern_brush` (vectorcraft-engine), `dialogs::brush_replace_art::tests::leave_strokes_adds_a_copy_and_cancel_changes_nothing`, `panels::brushes::tests::art_alt_dropped_on_a_brush_with_art_replaces_its_art`, `canvas::tests::art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original` (vectorcraft-ui-egui). In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). From 00624da7ad610cc64080cab91e3f8fa03b4b5a3d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:13:41 -0800 Subject: [PATCH 081/126] MCP: preferences replies leave out folders outside the read roots Under --automation-read-root, prefs.get, prefs.list, prefs.set and prefs.reset returned the folder preferences (fontsFolder, pluginsFolder, scratchPrimary, scratchSecondary, recoveryFolder, templatesFolder) and the export presets' folder even when they were outside the read roots. Such a folder preference is now null, as ui.inspect leaves it out, and such a preset's folder is left out. An empty folder and the scratch disk choices stay; without roots the replies are unchanged. --- crates/engine/src/cmd/prefscmds.rs | 46 +++++++++-- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/prefs_roots.rs | 107 +++++++++++++++++++++++++ docs/mcp.md | 5 ++ docs/target-app-parity.md | 1 + 5 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/tests/prefs_roots.rs diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a92b4095a9..014e825684 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -765,7 +765,7 @@ impl Session { pub fn specs() -> Vec<CommandSpec> { vec![ - cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get), + cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get), cmd!( query "prefs.set", "Set Preference", @@ -776,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> { set ), cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset), - cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list), + cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list), cmd!( query "prefs.export", "Export Preferences", @@ -882,8 +882,42 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "imported": values.len() })) } +/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks. +const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not folders. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the +/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside +/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is +/// left out. An empty value (the default folder) and a scratch +/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are. +fn shown_prefs(prefs: &Prefs) -> Value { + let mut all = prefs.to_json(); + if crate::file_access::current().is_none() { + return all; + } + // The app trims a folder before it uses it. + let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err(); + for key in PATH_PREFS { + let Some(path) = all[key].as_str() else { continue }; + if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) { + all[key] = Value::Null; + } + } + if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) { + for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) { + if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) { + settings.remove("folder"); + } + } + } + all +} + fn get(s: &mut Session, p: &Value) -> Result<Value> { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); match str_param(p, "key") { Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()), Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))), @@ -911,7 +945,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> { if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect())) } @@ -925,11 +959,11 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> { let mut next = s.prefs.clone(); next.reset(cat); s.apply_prefs(next); - Ok(json!({ "prefs": s.prefs.to_json() })) + Ok(json!({ "prefs": shown_prefs(&s.prefs) })) } fn list(s: &mut Session, _: &Value) -> Result<Value> { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Array( PREF_SPECS .iter() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f5bbd54220..67a7bc308d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -133,6 +133,7 @@ mod placed_document; mod plugins; mod prefs; mod prefs_file; +mod prefs_roots; mod previewbounds; mod print; mod printadvanced; diff --git a/crates/engine/src/tests/prefs_roots.rs b/crates/engine/src/tests/prefs_roots.rs new file mode 100644 index 0000000000..b9818949f1 --- /dev/null +++ b/crates/engine/src/tests/prefs_roots.rs @@ -0,0 +1,107 @@ +//! The preferences commands under automation roots: a confined request sees a folder preference and +//! an export preset's folder only inside its read roots. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::cmd::fileio::ScreenExportPreset; +use crate::file_access::{AutomationRoots, confine}; +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn text(p: &Path) -> String { + p.to_string_lossy().to_string() +} + +/// A fresh scratch folder for one test, with the folders `in` and `out`. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-prefs-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for sub in ["in", "out"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + } + dir +} + +/// The folder preferences that name the folder outside the read root. +const OUTSIDE: [&str; 3] = ["pluginsFolder", "scratchPrimary", "templatesFolder"]; + +/// A session whose folder preferences name `inside` (fonts, and the Data Recovery folder with +/// spaces around it) and `outside` (the [`OUTSIDE`] ones, the Templates folder with spaces around +/// it), with a scratch disk choice and an export preset kept with each folder. +fn session(inside: &str, outside: &str) -> Session { + let mut s = Session::new(); + s.prefs.fonts_folder = inside.to_string(); + s.prefs.recovery_folder = format!(" {inside} "); + s.prefs.plugins_folder = outside.to_string(); + s.prefs.scratch_primary = outside.to_string(); + s.prefs.scratch_secondary = "None".to_string(); + s.prefs.templates_folder = format!(" {outside} "); + for (name, folder) in [("Inside", inside), ("Outside", outside)] { + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.prefs.screen_export_presets.push(ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() }); + } + s +} + +/// Does a string anywhere in `v` hold `path`? +fn holds(v: &Value, path: &str) -> bool { + v.to_string().contains(&path.replace('\\', "\\\\")) +} + +#[test] +fn prefs_commands_leave_out_folders_outside_the_read_root() { + let base = folder("get"); + let (inside, outside) = (text(&base.join("in").join("fonts")), text(&base.join("out"))); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let mut s = session(&inside, &outside); + + let all = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({}))); + let prefs = &all["prefs"]; + assert_eq!(prefs["fontsFolder"], json!(inside), "a folder inside the root is shown"); + assert_eq!(prefs["recoveryFolder"], json!(format!(" {inside} ")), "checked without the spaces, shown as kept"); + assert_eq!(prefs["scratchSecondary"], json!("None"), "a scratch disk choice is shown"); + for key in OUTSIDE { + assert_eq!(prefs[key], Value::Null, "{key}: {all}"); + } + let presets = prefs["screenExportPresets"].as_array().unwrap(); + assert_eq!(presets[0]["settings"]["folder"], json!(inside)); + assert_eq!(presets[1]["name"], json!("Outside"), "the preset stays"); + assert!(presets[1]["settings"].get("folder").is_none(), "{all}"); + assert!(!holds(&all, &outside), "{all}"); + + for key in OUTSIDE { + let one = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": key}))); + assert_eq!(one, Value::Null, "{key}"); + } + assert_eq!(confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": "fontsFolder"}))), json!(inside)); + + let list = confine(roots.as_ref(), || run(&mut s, "prefs.list", json!({}))); + assert!(!holds(&list, &outside), "{list}"); + let row = |key: &str| list.as_array().unwrap().iter().find(|r| r["key"] == key).cloned().unwrap(); + assert_eq!(row("fontsFolder")["value"], json!(inside)); + assert_eq!(row("pluginsFolder")["value"], Value::Null); + assert_eq!(row("pluginsFolder")["kind"], json!("text"), "the row stays"); + + // A request that sets another preference, or resets a category without folders. + let set = confine(roots.as_ref(), || run(&mut s, "prefs.set", json!({"values": {"keyboardIncrement": 2, "pluginsFolder": outside}}))); + assert_eq!(set, json!({"keyboardIncrement": 2.0, "pluginsFolder": null}), "a folder kept as it was is left out too"); + let reset = confine(roots.as_ref(), || run(&mut s, "prefs.reset", json!({"category": "General"}))); + assert!(!holds(&reset, &outside), "{reset}"); + assert_eq!(reset["prefs"]["fontsFolder"], json!(inside)); + + // Unconfined and with only a write root. + let all = run(&mut s, "prefs.get", json!({})); + assert_eq!(all["prefs"]["pluginsFolder"], json!(outside), "unconfined, the folder is shown"); + assert_eq!(all["prefs"]["screenExportPresets"][1]["settings"]["folder"], json!(outside)); + let write_only = AutomationRoots::new(&[], &[&base.join("in")]).unwrap(); + let all = confine(write_only.as_ref(), || run(&mut s, "prefs.get", json!({}))); + assert_eq!(all["prefs"]["fontsFolder"], Value::Null, "without a read root no folder is shown"); + assert_eq!(all["prefs"]["recoveryFolder"], Value::Null); + assert_eq!(s.prefs.plugins_folder, outside, "the preferences are unchanged"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..961d56b053 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -153,6 +153,11 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots + (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as + `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and + `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the + read roots, and the preset stays. The preferences themselves are not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..5fbe4157f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -753,6 +753,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From 08be14ec3cbe27dbf8e5a94c57aa969728eed08b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:14:35 -0800 Subject: [PATCH 082/126] Docs: merge the two DXF entries in gaps.md into one The older entry said a non-circular ellipse keeps the fallback; R13 and later write it as a native ELLIPSE and R12 keeps the fallback. --- docs/gaps.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 3082873bb4..e6d171cfe8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -323,8 +323,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). - **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1). From 023d2c6c396368b9873345df8fb3025680938e41 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:26:03 -0800 Subject: [PATCH 083/126] Plug-ins: ui.installPlugin's description says it answers null when the web picker opens --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 950adca0c8..7a8fd89cbc 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1523,7 +1523,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked | null on the web without path or dataBase64, once the browser's file picker opens (the picked file is handled as above)", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 489567bf38..cc35f01bb0 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1524,7 +1524,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked | null on the web without path or dataBase64, once the browser's file picker opens (the picked file is handled as above)", ), ( "plugin.cancelRuns", From 949553b6ce6d26abe53586949c45dc737a061f37 Mon Sep 17 00:00:00 2001 From: Dan Green <danngreen1@gmail.com> Date: Wed, 11 Nov 2026 07:19:39 -0800 Subject: [PATCH 084/126] Rulers: the origin dragged out of the rulers' corner snaps as a drawn point does The zero point dragged out of the box where the rulers meet snaps as a Pen or shape tool's point does: with Smart Guides onto the anchors, centres, edges and paths of the art and the artboards or into line with them, with Snap to Point onto the anchors and along the ruler guides, else onto whole pixels or the grid. The guides showing what it snapped to show during the drag and go when it is released or cancelled (Escape, focus loss, another document). The origin is still set once, on release (document.setRulerOrigin, one undo step). The engine snaps it (Session::ruler_origin_drag, with the tools crate's rulerguide::OriginDrag over PointSnap::drawn), as it does a guide dragged out of a ruler. --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/menucmds.rs | 35 ++++++++++++++++++++++ crates/engine/src/tooling.rs | 24 +++++++++++++++ crates/tools/src/guides/snap.rs | 7 +++++ crates/tools/src/rulerguide.rs | 38 ++++++++++++++++++++++-- crates/ui-egui/src/canvas/grid.rs | 17 +++++++++-- crates/ui-egui/src/tests/ruler_origin.rs | 22 ++++++++++++++ docs/target-app-parity.md | 5 ++-- docs/ui-parity.md | 5 ++-- 10 files changed, 147 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f1f188f00c..a54e7e9e44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -306,6 +306,8 @@ pub struct Session { pub(crate) press_point: Option<Option<(usize, cmd::gradient::StopOwner)>>, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>, + /// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]). + pub(crate) ruler_origin: Option<vectorcraft_tools::rulerguide::OriginDrag>, /// Which selected type overflows its frame, for the ports every tool but the Type tools shows. pub(crate) port_marks: vectorcraft_tools::text::PortMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 83a0367751..2005ffd2aa 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -124,6 +124,7 @@ impl Session { press_type_selection: None, press_point: None, ruler_guide: None, + ruler_origin: None, port_marks: Default::default(), font_search: None, link_search: None, diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 28cb0a1590..44a0ddde1b 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -4489,6 +4489,41 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on( assert_eq!(owners(&mut s)[4..], [Value::Null, json!(1)]); } +/// The rulers' origin dragged out of their corner snaps as a drawn point does: onto a shape's +/// corner and edge, the artboard's centre, and with Snap to Point along a ruler guide; with +/// nothing on it stays at the pointer. The drag changes nothing, and its guides go when it ends. +#[test] +fn the_ruler_origin_dragged_from_the_corner_snaps_as_a_drawn_point() { + use vectorcraft_geom::Point; + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + let (n, doc) = (undo_len(&s), s.doc().unwrap().doc.clone()); + let v = ViewInfo::default(); + let at = |s: &mut Session, x, y| s.ruler_origin_drag(Some(Point::new(x, y)), v).unwrap(); + assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0), "the corner"); + assert!(!s.overlays(v).is_empty(), "the guide showing it"); + assert_eq!(at(&mut s, 130.0, 202.0), Point::new(130.0, 200.0), "the bottom edge"); + assert_eq!(at(&mut s, 398.0, 301.5), Point::new(400.0, 300.0), "the artboard's centre"); + assert_eq!(at(&mut s, 302.0, 50.0).x, 300.0, "along the ruler guide"); + assert_eq!(s.ruler_origin_drag(None, v), None, "off the canvas"); + assert!(s.overlays(v).is_empty()); + assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0)); + s.end_ruler_origin_drag(); + assert!(s.overlays(v).is_empty()); + assert_eq!((undo_len(&s), &*s.doc().unwrap().doc), (n, &*doc), "nothing changed"); + // Nothing on: it stays at the pointer. + let off = ViewInfo { smart_guides: false, snap_to_point: false, ..v }; + assert_eq!(s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), off), Some(Point::new(101.5, 98.5))); + s.end_ruler_origin_drag(); + // Snap to Grid: onto a gridline crossing. + let grid = ViewInfo { snap_to_grid: true, ..v }; + let p = s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), grid).unwrap(); + s.end_ruler_origin_drag(); + assert_eq!(p, Point::new(p.x.round(), p.y.round()), "{p:?}"); + assert_ne!(p, Point::new(101.5, 98.5)); +} + /// Guides saved before guides had layers are put on a layer when the document opens. #[test] fn guides_from_older_files_are_put_on_a_layer() { diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index b222f32107..9bc450f937 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -751,6 +751,27 @@ impl Session { self.apply_actions(acts).map(drop) } + /// The rulers' origin dragged out of their corner, whatever the tool, with the pointer at `p` in + /// document space (none off the canvas): where it goes, snapped as a drawn point is + /// ([`vectorcraft_tools::rulerguide::OriginDrag`]), the targets gathered on the drag's first + /// call. It changes nothing: the caller sets the origin where the button is released + /// (`document.setRulerOrigin`) and ends the drag ([`Session::end_ruler_origin_drag`]). + pub fn ruler_origin_drag(&mut self, p: Option<Point>, view: ViewInfo) -> Option<Point> { + let drag = match self.ruler_origin.take() { + Some(d) => Some(d), + None => self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::OriginDrag::new(cx))), + }; + let Some(mut drag) = drag else { return p }; + let at = self.with_tool_cx(view, |_, cx| drag.pointer(cx, p)); + self.ruler_origin = Some(drag); + at + } + + /// The drag of the rulers' origin ended, released or cancelled: its guides go. + pub fn end_ruler_origin_drag(&mut self) { + self.ruler_origin = None; + } + /// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the /// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event. pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> { @@ -1112,6 +1133,9 @@ impl Session { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); } + if let Some(d) = &self.ruler_origin { + v.extend(d.overlays()); + } let deferred_preview = self.unapplied_interaction_preview(); if let Some(d) = self.active() { let w = self.prefs.perspective_widget; diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 32941c5f71..6f0b50d4b5 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -119,6 +119,13 @@ impl PointSnap { Self { targets } } + /// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the + /// window as targets ([`Targets::for_drawing`]), live text as Snap to Glyph asks. + pub fn drawn(cx: &ToolContext) -> Self { + let glyphs = (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap); + Self::new(cx, || Targets::for_drawing(cx.doc, &[], visible(cx), glyphs)) + } + /// Where `p` goes, and the guides showing why. pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) { self.snap_from(cx, p, None) diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 97d8bed289..a90770d3fb 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -9,13 +9,14 @@ //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its //! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden -//! or locked layer, can't be picked. +//! or locked layer, can't be picked. The rulers' origin, dragged out of their corner +//! ([`OriginDrag`]), snaps as a drawn point does. use serde_json::json; use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; -use crate::guides::Targets; +use crate::guides::{PointSnap, Targets}; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; /// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection @@ -247,6 +248,39 @@ impl NewGuide { } } +/// The rulers' origin dragged out of the box where they meet, with any tool: it snaps as a drawn +/// point does ([`PointSnap::drawn`]), with Smart Guides onto the anchors, centres, edges and paths +/// of the art and the artboards or into line with them, with Snap to Point onto the anchors and +/// along the ruler guides, else onto whole pixels or the grid. The targets are gathered when the +/// drag begins. It changes nothing: the origin is set where the button is released. +pub struct OriginDrag { + snap: PointSnap, + /// The guides showing what the origin snapped to. + guides: Vec<Overlay>, +} + +impl OriginDrag { + pub fn new(cx: &ToolContext) -> Self { + Self { snap: PointSnap::drawn(cx), guides: vec![] } + } + + /// Where the origin goes with the pointer at `p` (none off the canvas). + pub fn pointer(&mut self, cx: &ToolContext, p: Option<Point>) -> Option<Point> { + let Some(p) = p else { + self.guides.clear(); + return None; + }; + let (at, guides) = self.snap.snap(cx, p); + self.guides = guides; + Some(at) + } + + /// The guides showing what the origin snapped to. + pub fn overlays(&self) -> Vec<Overlay> { + self.guides.clone() + } +} + /// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for /// the rest of the gesture. #[derive(Default)] diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 7f000ee787..0f6fc7d912 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -181,8 +181,9 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { /// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the /// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the /// left; swapped in a view turned 90°, where the rulers read the other document axis, and the -/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a -/// double-click. +/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags +/// the rulers' origin, snapped as a drawn point is (`Session::ruler_origin_drag`), or resets it on +/// a double-click. pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> { let [top, left, corner] = ruler_rects(full); // Right-click on a ruler or the origin box: the document units, to swap between them as @@ -274,9 +275,18 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c let mut preview = None; if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) { cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)); - let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + let pointer = + corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + // The origin snaps as a drawn point does (Smart Guides, Snap to Point, pixels, the grid). + let at = if cancelled { + app.session.end_ruler_origin_drag(); + None + } else { + app.session.ruler_origin_drag(pointer, app.view_info()) + }; if corner.drag_stopped_by(egui::PointerButton::Primary) { ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); + app.session.end_ruler_origin_drag(); if !cancelled && let Some(at) = at && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) @@ -285,6 +295,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c } } else if !ui.input(|i| i.pointer.primary_down()) { ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); + app.session.end_ruler_origin_drag(); } else { ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); if !cancelled { diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index 365cc92ef7..55e20df8c6 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -88,6 +88,27 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert_eq!(app.ruler_origin(), origin); } +/// The origin snaps as a drawn point does: dragged next to a rectangle's corner, it is set on it. +#[test] +fn ruler_origin_drag_snaps_onto_the_art() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("select.none", json!({})).unwrap(); + // The Hand tool shows no smart guides of its own as the pointer passes over the corner. + app.select_tool("hand"); + let ctx = context(); + frame(&mut app, &ctx, vec![], canvas::show); + let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.4, 99.6)); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty()); + start_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, vec![], canvas::show); + assert!(!app.session.overlays(app.view_info()).is_empty(), "the guide showing what it snapped to"); + frame(&mut app, &ctx, vec![press(to, false)], canvas::show); + assert_eq!(app.ruler_origin(), Point::new(100.0, 100.0)); + assert!(app.session.overlays(app.view_info()).is_empty()); +} + #[test] fn ruler_origin_double_click_resets_to_the_active_artboard() { let mut app = app(); @@ -146,6 +167,7 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { Cancel::Outside => pos2(4.0, 4.0), Cancel::Escape => { frame(&mut app, &ctx, vec![key(Key::Escape)], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty(), "the snapping guides go too"); to } Cancel::Focus => { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..bda39a8e86 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -663,7 +663,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Ruler double-click (#5239):** a double-click (both clicks on that ruler) on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. egui frame tests cover both rulers, a lone click, Undo, a canvas click before a ruler click, and rotated views. -- **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). +- **Rulers (#1212):** drag the intersection to preview and set a document zero point, snapped as a drawn point is (with Smart Guides onto the art's and the artboards' anchors, centres, edges and paths or into line with them, with Snap to Point along the ruler guides, else to pixels or the grid), or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). - **Artboard guide labels (#6414, in part):** Artboard Options' Guide Label gives an artboard a color label (`artboard.setProps`/`artboard.new` `guideLabel`: a layer color's name, `#rrggbb` or null), and the canvas draws that artboard's own guides in it; canvas guides, guides of an unlabeled artboard and guides whose artboard is gone keep the guide color preference, and selected guides keep the selection color. The [help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) describes guides restricted to an artboard, not a label, so the label, its palette and the color precedence are VectorCraft's own. It is screen-only (exports and print leave it out), written to the native format only when set, kept by duplicates and copies, and changeable on a locked artboard; `artboard.setProps` adds no undo step when nothing changes. This VectorCraft addition does not raise a score. Regression tests: `tests/panelcmds.rs` (`artboard_guide_labels_are_set_and_cleared_in_one_undo_step`, `artboard_guide_labels_round_trip_through_the_native_format`), `doc` `tests.rs` (`guide_label_text_names_a_layer_color_or_rgb`, `artboard_guide_label_defaults_and_skips_in_json`), `canvas::tests` (`artboard_guide_label_colors_only_its_own_guides`), `dialogs/tests.rs` (`artboard_options_shows_sets_and_clears_the_guide_label`). @@ -2046,6 +2046,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | +| 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6a843326c0..512372c19d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -240,7 +240,7 @@ tools, never checked side by side) isn't captured row by row. | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | | A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | -| Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | +| Ruler origin: corner drag with preview, snapped as a drawn point is (Smart Guides, Snap to Point and the ruler guides, pixels, the grid), double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | | Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on, by the nearest point of the path when pressed on its stroke or beside its edge, so the path lands exactly on the guide | done | #1534 (#1543, #1583); #3701 | | A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim | @@ -520,6 +520,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | +| 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | From 917411a3541d4ba33467d68d16d346a7e439091d Mon Sep 17 00:00:00 2001 From: Dan Green <danngreen1@gmail.com> Date: Wed, 11 Nov 2026 15:15:22 -0800 Subject: [PATCH 085/126] Rulers: hiding the rulers during a drag of their origin cancels it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag out of the rulers' corner was ended only by the rulers' own input, which the canvas skips while the rulers are hidden (View › Rulers, Presentation Mode). Hiding them mid-drag left the drag's snapping guides on the canvas and its state open until the rulers came back. The canvas now ends the drag, cancelled, while the rulers are hidden, through the same helper a release uses. --- crates/ui-egui/src/canvas.rs | 4 ++-- crates/ui-egui/src/canvas/grid.rs | 21 +++++++++++++++++---- crates/ui-egui/src/tests/ruler_origin.rs | 11 ++++++++++- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 50136f1a9c..6e6806e075 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,7 +50,7 @@ use fill::fill_uncovered; use fill::{fill_view, polygon_area}; #[cfg(test)] use grid::ruler_rects; -use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds}; +use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, rulers_hidden, textured_quad, view_bounds}; pub(crate) use grid::{RulerScale, checker_texture, ruler_label, ruler_origin_dragging}; #[cfg(test)] pub(crate) use grid::{TRANSPARENCY_GRID, vertical_label_height}; @@ -283,7 +283,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.ctx().request_repaint_after_secs(secs as f32); } let hover_still = wait.is_none(); - let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None }; + let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { rulers_hidden(app, ui) }; crate::document_drag::drop_on_canvas(app, &resp, &xf); panel_drop(app, ui, &resp, &xf); // In Presentation Mode a right click or a long touch opens no context menu. diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 0f6fc7d912..2c99c618db 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -285,8 +285,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c app.session.ruler_origin_drag(pointer, app.view_info()) }; if corner.drag_stopped_by(egui::PointerButton::Primary) { - ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); - app.session.end_ruler_origin_drag(); + end_ruler_origin_drag(app, ui); if !cancelled && let Some(at) = at && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) @@ -294,8 +293,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c app.status(e.to_string()); } } else if !ui.input(|i| i.pointer.primary_down()) { - ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); - app.session.end_ruler_origin_drag(); + end_ruler_origin_drag(app, ui); } else { ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); if !cancelled { @@ -306,6 +304,21 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c preview } +/// The rulers are hidden (View › Rulers, Presentation Mode): a drag of their origin they were +/// showing for is cancelled, so its snapping guides don't stay on the canvas. Nothing previews. +pub(super) fn rulers_hidden(app: &mut VectorcraftApp, ui: &Ui) -> Option<Point> { + if ruler_origin_dragging(ui.ctx()) { + end_ruler_origin_drag(app, ui); + } + None +} + +/// Ends a drag from the rulers' corner, released or cancelled: its state and its snapping guides go. +fn end_ruler_origin_drag(app: &mut VectorcraftApp, ui: &Ui) { + ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(ruler_origin_drag_id())); + app.session.end_ruler_origin_drag(); +} + /// The key of the ruler origin drag's state in egui's temporary data. fn ruler_origin_drag_id() -> egui::Id { egui::Id::unique("ruler-origin-drag") diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index 55e20df8c6..5e93993fe3 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -153,8 +153,9 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { Escape, Focus, Document, + HideRulers, } - for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document] { + for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document, Cancel::HideRulers] { let mut app = app(); app.run("document.setRulerOrigin", json!({"x": 25, "y": 30})).unwrap(); let ctx = context(); @@ -179,6 +180,14 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); to } + Cancel::HideRulers => { + assert!(!app.session.overlays(app.view_info()).is_empty(), "the snapping guides show"); + app.ui.view.rulers = false; + frame(&mut app, &ctx, vec![], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty(), "the snapping guides go with the rulers"); + assert!(!canvas::ruler_origin_dragging(&ctx)); + to + } }; frame(&mut app, &ctx, vec![Event::PointerMoved(release), press(release, false)], canvas::show); if cancel == Cancel::Document { From 691e5a8fb5245d1eb8b31edf2c84897161279b4d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:36:30 -0800 Subject: [PATCH 086/126] Shortcuts: a single-key shortcut opens its menu item's slider or dialog A command given a key without a modifier did not open the slider or dialog that its menu item and its chords open. With Smooth on F, pressing F opened no slider, and Offset Path, Reflect, Average, Split Into Grid, Vector Halftone, Divide into Dashes and Find Nearest Swatch changed the document at once with their default values. The single-key handler ran the command directly, while a chord goes through the menu's route. Single-key presses now take the chord's route (run_hit), so Smooth and Simplify show their sliders, and Offset Path, Move, Rotate, Scale, Reflect, Shear, Transform Each, Average, Average & Join, Split Into Grid, Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into Dashes and Find Nearest Swatch open their dialogs. Commands without a dialog still run at once. On that route a single key also finishes a pending tool edit before its command runs, as a chord does. Holding a key that opened a dialog or the palette no longer types into it: the key's repeats and the text they type are held back until the key is let go. With Move on F, holding F typed "ffffffff" into Move's first field. A shortcut that opens a dialog or the palette also stops the shortcuts after it in the same frame, single keys and chords alike, so they no longer change the document behind the dialog. --- crates/ui-egui/src/shortcuts.rs | 74 +++++++++- crates/ui-egui/src/shortcuts/tests.rs | 194 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index df87187a65..83d53d700c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -194,6 +194,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); note_key_surfaces(app, ctx); + forget_opening_key(ctx); let focused = ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT; // A held tool shortcut that is let go returns to the tool active before it. held_tool::follow(app, ctx, focused); @@ -364,6 +365,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let ran = !hits.is_empty(); for c in hits { run_hit(app, ctx, &c); + if dialog_or_palette_open(app) { + break; + } } if settings_on_a_mac(app, ctx, mac(ctx)) || ran { return; @@ -423,10 +427,54 @@ fn note_key_surfaces(app: &VectorcraftApp, ctx: &egui::Context) { /// Removes this frame's presses of Enter and Escape when the key went down on something that has /// since changed: the press that opened or closed a dialog, and its repeats until the key is let -/// go, reach neither the new dialog nor what is under the old one. +/// go, reach neither the new dialog nor what is under the old one. Also removes the repeats of a +/// key without a modifier that opened a dialog or the palette ([`hold_back_opening_key`]). pub(crate) fn hold_back_keys(app: &VectorcraftApp, ctx: &egui::Context) { hold_back_key(app, ctx, Key::Enter); hold_back_key(app, ctx, Key::Escape); + hold_back_opening_key(ctx); +} + +/// The key of the mark of a key without a modifier whose command opened a dialog or the palette. +fn opening_key_id(ctx: &egui::Context) -> egui::Id { + egui::Id::unique("opening-key").with(ctx.viewport_id()) +} + +/// Removes this frame's repeats of the key without a modifier that opened a dialog or the +/// palette, and the text they type, until that key is let go: a held key does not type into the +/// field that takes the keyboard. +fn hold_back_opening_key(ctx: &egui::Context) { + let id = opening_key_id(ctx); + let Some(key) = ctx.data(|m| m.get_temp::<Key>(id)) else { return }; + let repeat_of = |e: Option<&egui::Event>| matches!(e, Some(egui::Event::Key { key: k, pressed: true, repeat: true, .. }) if *k == key); + ctx.input_mut(|i| { + let typed_by = typing_presses(&i.events); + let held: Vec<bool> = i + .events + .iter() + .zip(typed_by) + .map(|(e, by)| repeat_of(Some(e)) || matches!(e, egui::Event::Text(_)) && repeat_of(by.and_then(|k| i.events.get(k)))) + .collect(); + let mut held = held.into_iter(); + i.events.retain(|_| !held.next().unwrap_or(false)); + }); + // A repeat that comes in the same frame as the release is held back above; later presses are new. + let released = + ctx.input(|i| !i.key_down(key) || i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, pressed: false, .. } if *k == key))); + if released { + ctx.data_mut(|m| m.remove::<Key>(id)); + } +} + +/// Forgets the key that opened a dialog or the palette ([`hold_back_opening_key`]) once it is up +/// and this frame has none of its events. +fn forget_opening_key(ctx: &egui::Context) { + let id = opening_key_id(ctx); + if let Some(key) = ctx.data(|m| m.get_temp::<Key>(id)) + && ctx.input(|i| !i.key_down(key) && !i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, .. } if *k == key))) + { + ctx.data_mut(|m| m.remove::<Key>(id)); + } } fn hold_back_key(app: &VectorcraftApp, ctx: &egui::Context, key: Key) { @@ -549,17 +597,30 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; + // A command runs as it does on a chord ([`run_hit`]): what its menu item runs. Smooth opens + // its slider, and Move opens its dialog. match hit.id.strip_prefix("tool:") { Some(tool) => held_tool::choose(app, ctx, tool, pressed, repeat), - None if let Some((id, p)) = menu_target(hit) => crate::menus::invoke(app, &id, p), - None if hit.params.is_null() => { - let _ = app.run(&hit.id, json!({})); + None => { + run_hit(app, ctx, hit); + if dialog_or_palette_open(app) { + if let Some(key) = pressed { + let id = opening_key_id(ctx); + ctx.data_mut(|m| m.insert_temp(id, key)); + } + break; + } } - None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)), } } } +/// Is a dialog or the palette open? A shortcut that opens one stops the shortcuts after it in the +/// same frame. +fn dialog_or_palette_open(app: &VectorcraftApp) -> bool { + app.ui.dialog.is_some() || app.ui.palette_open +} + /// Route Alt+arrows to spacing controls when text objects are selected. fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let m = ctx.input(|i| i.modifiers); @@ -615,6 +676,9 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd); for c in hits { run_hit(app, ctx, &c); + if dialog_or_palette_open(app) { + break; + } } settings_on_a_mac(app, ctx, mac(ctx)); } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 44d438b73b..8d32a4de22 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -292,6 +292,200 @@ fn a_shortcut_runs_what_its_menu_item_runs() { } } +/// Commands whose menu item opens a slider or a dialog before anything changes: the sliders of +/// Smooth and Simplify, Offset Path, the Object › Transform dialogs, Average, Split Into Grid, +/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into +/// Dashes, Find Nearest Swatch, and four "…" items whose menu entry gives params. +const MENU_DIALOG_COMMANDS: [&str; 22] = [ + "object.path.smooth", + "object.path.simplify", + "object.path.offsetPath", + "object.move", + "object.rotate", + "object.scale", + "object.reflect", + "object.shear", + "object.transformEach", + "path.average", + "path.averageJoin", + "object.path.splitIntoGrid", + "object.vectorHalftone", + "artboard.rearrange", + "object.pathfinder.options", + "addon.arrange.grid", + crate::addons::pathtools::dashes::COMMAND, + crate::addons::color::nearest::COMMAND, + "object.path.cleanUp", + "object.mesh.create", + "type.smartPunctuation", + "edit.findReplace", +]; + +/// F pressed without a modifier. +fn press_f() -> Vec<egui::Event> { + vec![ + egui::Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::Text("f".into()), + ] +} + +/// An app with a selected rectangle where `id` has the shortcut `shortcut` and F runs nothing else. +fn app_with_shortcut(id: &str, shortcut: &str) -> VectorcraftApp { + let mut app = app_with_a_selected_rectangle(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, id, shortcut); + app.ui.status.clear(); + app +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// A command given a key without a modifier runs what its menu item runs, as it does on a chord: +/// its slider or dialog opens and nothing changes until it is confirmed. +#[test] +fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { + let chord = vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ]; + let mut failures = vec![]; + for id in MENU_DIALOG_COMMANDS { + let mut opened = |shortcut: &str, events: Vec<egui::Event>| { + let mut app = app_with_shortcut(id, shortcut); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + if undo_len(&app) != before { + failures.push(format!("{id} on {shortcut} changed the document: {}", app.ui.status)); + } + app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.get("__command").cloned())) + }; + let by_key = opened("F", press_f()); + let by_chord = opened("Cmd+Alt+Shift+F9", chord.clone()); + if by_key.is_none() || by_key != by_chord { + failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// Smooth on F opens its slider on a jagged path without smoothing it; confirming the slider keeps +/// the smoothed path as one undo step. +#[test] +fn smooth_on_a_single_key_waits_for_its_slider() { + let mut app = app_with_shortcut("object.path.smooth", "F"); + app.session.execute("edit.clear", &json!({})).unwrap(); + let anchors: Vec<_> = + (0..=60).map(|i| json!({"x": 20 + i * 5, "y": 150.0 + 40.0 * (f64::from(i) / 10.0).sin() + f64::from(i % 2) * 3.0})).collect(); + app.session.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let before = undo_len(&app); + frame(&mut app, press_f()); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(open_dialog(&app).as_deref(), Some(crate::dialogs::smooth_slider::KIND), "{}", app.ui.status); + assert_eq!(undo_len(&app), before, "the path is not smoothed yet"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(undo_len(&app), before + 1, "the slider's result is one undo step"); +} + +/// A command without a dialog still runs at once on a key without a modifier: Group given F, and +/// Swap Fill and Stroke on its default Shift+X. +#[test] +fn a_single_key_shortcut_without_a_dialog_runs_at_once() { + let mut app = app_with_shortcut("object.group", "F"); + let before = undo_len(&app); + frame(&mut app, press_f()); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "F groups: {}", app.ui.status); + let mut app = app_with_a_selected_rectangle(); + let before = undo_len(&app); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::X, physical_key: Some(Key::X), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text("X".into()), + ], + ); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "Shift+X swaps fill and stroke: {}", app.ui.status); +} + +/// With no document open, a command's key without a modifier reports what the same command +/// reports on a chord. +#[test] +fn a_single_key_shortcut_reports_what_its_chord_reports() { + let status = |shortcut: &str, events: Vec<egui::Event>| { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "paint.default", shortcut); + app.ui.status.clear(); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + (open_dialog(&app), app.ui.status.clone()) + }; + let by_key = status("F", press_f()); + let by_chord = status( + "Cmd+Alt+Shift+F9", + vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ], + ); + assert_eq!(by_key, by_chord); + assert!(!by_key.1.is_empty(), "the chord reports something: {by_chord:?}"); +} + +/// Holding a key without a modifier that opens a dialog types nothing into the dialog: neither the +/// key's character nor its repeats reach the field that takes the keyboard. +#[test] +fn holding_a_single_key_that_opens_a_dialog_types_nothing_into_it() { + // Move on F: the dialog and the text of the field with the keyboard after the press, eight + // more frames with F held down (repeating) or not, and the release, which comes in the same + // frame as a last repeat. + let hold = |repeats: bool| { + let mut app = app_with_shortcut("object.move", "F"); + let held = HeldKey::new(Key::F, Modifiers::NONE); + held.frame(&mut app, press_f()); + for _ in 0..8 { + held.frame(&mut app, if repeats { press_f() } else { vec![] }); + } + let release = egui::Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: Modifiers::NONE }; + held.frame(&mut app, if repeats { [press_f(), vec![release]].concat() } else { vec![release] }); + let field = held.ctx.memory(|m| m.focused()).and_then(|id| held.ctx.data_mut(|d| d.get_temp::<String>(id))); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + (open_dialog(&app), field, app.ui.status.clone()) + }; + let let_go = hold(false); + assert_eq!(let_go.0.as_deref(), Some("move"), "{}", let_go.2); + assert!(let_go.1.is_some(), "its first field has the keyboard"); + assert_eq!(hold(true), let_go, "the held F typed nothing into Move"); +} + +/// A key that opens a dialog stops the keys after it in the same frame: G, given Group, does not +/// group the selection behind Move's dialog. The same holds for chords. +#[test] +fn keys_after_one_that_opens_a_dialog_in_the_same_frame_run_nothing() { + let mods = cmd() | Modifiers::ALT | Modifiers::SHIFT; + let chord = |key: Key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }; + let single = |key: Key, text: &str| { + [egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }, egui::Event::Text(text.into())] + }; + let runs = [ + ("F", "G", [single(Key::F, "f"), single(Key::G, "g")].concat()), + ("Cmd+Alt+Shift+F9", "Cmd+Alt+Shift+F10", vec![egui::Event::ModifiersChanged(mods), chord(Key::F9), chord(Key::F10)]), + ]; + for (move_key, group_key, events) in runs { + let mut app = app_with_shortcut("object.move", move_key); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "object.group", group_key); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app).as_deref(), undo_len(&app)), (Some("move"), before), "{move_key} then {group_key}: {}", app.ui.status); + } +} + /// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard /// tool's Shift+O), with the Selection tool or while the Type tool edits the text. #[test] From 46efc4a651364a2d9ef38761efbc73a77aa18ac3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:39:04 -0800 Subject: [PATCH 087/126] Scripts: a String object counts its rope's copy when a character is read - wrapping a string no longer flattens it, so Object(s).length and new String(s) on a large concatenated string make no copy and cannot collect while a call's operands are off the stack - reading a String object's character by index, `in` and hasOwnProperty count the flattened copy at the next limit check, and the run ends before the copy when the fuel for it is not left - unary plus on an object whose valueOf returns a concatenated string, and a property key from toString, keep that string rooted while its copy is reserved - the unshift comment cites step 6 of section 15.4.4.13 --- crates/js/src/builtins/array.rs | 2 +- crates/js/src/interp.rs | 80 ++++++++++++++++++-------------- crates/js/src/realm.rs | 1 + crates/js/src/string_builtins.rs | 3 -- crates/js/src/text_tests.rs | 55 +++++++++++++++++++--- 5 files changed, 94 insertions(+), 47 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 838fc4ff98..bfb4de5eac 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -479,7 +479,7 @@ fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul with_this(interp, &this, |interp, o| { let len = u64::from(length_of(interp, o)?); let count = args.len() as u64; - // With no arguments each element is still read and written back in place (steps 6 and 7). + // With no arguments each element is still read and written back in place (step 6). let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; while let Some(k) = it.next(interp)? { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 82d0c8f9ac..ace43fe242 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -205,6 +205,11 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc<HeldCount>, + /// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String + /// object, which the heap's total adds before the next limit check. + flattened: std::cell::Cell<usize>, + /// Fuel for the units those copies took, which the next step takes. + pub(crate) flattened_fuel: std::cell::Cell<u64>, /// The global code's default XML namespace URI (ECMA-357 section 12.1). pub(crate) xml_default_uri: Key, /// Set by the host to end the current slice of a resumable run at the next safe point. @@ -450,6 +455,8 @@ impl Interp { files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + flattened: std::cell::Cell::new(0), + flattened_fuel: std::cell::Cell::new(0), xml_default_uri: Rc::from(Vec::new()), pause: Rc::default(), in_slice: false, @@ -569,6 +576,7 @@ impl Interp { fn tick(&mut self) -> JsResult<()> { self.use_fuel()?; + crate::regexp_vm::use_fuel_n(self, self.flattened_fuel.replace(0))?; self.settle_held(); if self.heap.bytes > self.gc_threshold { self.collect_garbage(); @@ -595,10 +603,12 @@ impl Interp { Ok(()) } - /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total. + /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total, and adds the + /// bytes of the ropes [`Self::get_own_property`] flattened. fn settle_held(&mut self) { let released = self.held.released.replace(0); - self.heap.bytes = self.heap.bytes.saturating_sub(released); + let flattened = self.flattened.replace(0); + self.heap.bytes = self.heap.bytes.saturating_sub(released).saturating_add(flattened); } /// Collects garbage now and sets when the next collection runs. @@ -664,6 +674,7 @@ impl Interp { self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. self.held.released.set(0); + self.flattened.set(0); self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get()); } @@ -1214,6 +1225,17 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { + if s.is_rope() { + // Flattening copies the units. The run ends here when the fuel for the + // copy is not left; otherwise the next step takes it, and the next limit + // check adds the copy's bytes. + let fuel = self.flattened_fuel.get().saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); + if fuel > self.fuel { + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.flattened_fuel.set(fuel); + self.flattened.set(self.flattened.get().saturating_add(string_bytes(s.len()))); + } let unit = s.flat().get(i as usize).copied().unwrap_or(0); return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE })); } @@ -2216,19 +2238,26 @@ impl Interp { let arguments = if code.uses_arguments { OBJECT_BYTES.saturating_add(args.len().saturating_add(4).saturating_mul(binding_bytes(10))) } else { 0 }; let need = declaration_bytes(&code).saturating_add(ENV_BYTES).saturating_add(params).saturating_add(arguments); - // The operands are off the stack. Reserving the heap collects, and so can boxing a string - // receiver, which flattens it; the operands stay rooted until the frame holds them. - let collects = self.heap.bytes.saturating_add(need) > self.limits.heap_bytes || (!code.strict && matches!(this, Value::String(_))); - let mark = self.temp_roots.len(); - if collects { + if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { + let mark = self.temp_roots.len(); self.temp_roots.push(Value::Object(callee)); self.temp_roots.push(this.clone()); self.temp_roots.extend(construct.map(Value::Object)); self.temp_roots.extend(args.iter().cloned()); + let reserved = self.reserve_heap(need); + self.temp_roots.truncate(mark); + reserved?; } - let received = self.receive_call(&code, env, this, &args, callee, need); - self.temp_roots.truncate(mark); - let (this, fenv) = received?; + let this = if code.strict { + this + } else { + match this { + Value::Undefined | Value::Null => Value::Object(self.global), + Value::Object(_) => this, + prim => Value::Object(self.to_object(&prim)?), + } + }; + let fenv = self.instantiate_function(&code, env, &args, callee)?; let base = self.stack.len(); self.frames.push(Frame { code, @@ -2246,25 +2275,6 @@ impl Interp { Ok(()) } - /// Reserves the `need` bytes of a call, boxes a primitive receiver for non-strict code and - /// binds the parameters, returning the receiver and the function's environment. - fn receive_call(&mut self, code: &Rc<Code>, env: EnvId, this: Value, args: &[Value], callee: ObjId, need: usize) -> JsResult<(Value, EnvId)> { - if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { - self.reserve_heap(need)?; - } - let this = if code.strict { - this - } else { - match this { - Value::Undefined | Value::Null => Value::Object(self.global), - Value::Object(_) => this, - prim => Value::Object(self.to_object(&prim)?), - } - }; - let fenv = self.instantiate_function(code, env, args, callee)?; - Ok((this, fenv)) - } - /// Declaration binding instantiation for function code (section 10.5). Each parameter and /// declaration takes a unit of fuel. fn instantiate_function(&mut self, code: &Rc<Code>, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult<EnvId> { @@ -2578,7 +2588,8 @@ impl Interp { Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; - return self.to_number(&p); + // Rooted, so what a string holds is still counted while its copy is reserved. + return self.rooted(std::slice::from_ref(&p), |i| i.to_number(&p)); } }) } @@ -2613,7 +2624,8 @@ impl Interp { Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), _ => { let s = self.to_str(v)?; - self.flat_reserved(&s) + // Rooted, so what the string holds is still counted while its copy is reserved. + self.rooted(&[Value::String(s.clone())], |i| i.flat_reserved(&s)) } } } @@ -2638,11 +2650,7 @@ impl Interp { Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), - // Flattened now, with its copy counted, so reading a character copies nothing. - Value::String(s) => { - flat_charged(self, s)?; - (Class::String(s.clone()), self.intrinsics.string_proto) - } + Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) } diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f274c42ead..431e9d8f49 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -230,6 +230,7 @@ impl Realm { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); } interp.fuel = interp.limits.fuel; + interp.flattened_fuel.set(0); interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index 85a27e3261..b3758bdf54 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -214,9 +214,6 @@ fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) - if !construct { return Ok(Value::String(s)); } - // Flattened now, with its copy counted, so reading a character copies nothing. The string - // stays rooted, so what it holds is still counted if reserving the copy collects. - interp.rooted(&[Value::String(s.clone())], |interp| flat_charged(interp, &s))?; let proto = interp.intrinsics.string_proto; Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 0c2f051f80..3d3455544e 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -355,22 +355,63 @@ fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { } #[test] -fn a_string_wrapper_counts_what_its_rope_holds_while_the_copy_is_made() { +fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { // `mk` returns a rope whose left half is a flat string of about half the heap, held by // nothing else. Its flattened copy is the other half, so the two don't fit together. let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ function mk() { var a = f('a'); a[0]; return a + 'b'; }"; let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; - let src = format!("{build} new String({{ toString: mk }}); 0"); - let result = Realm::with_options(Options::default(), limits).run(&src); - assert!(matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)), "{result:?}"); + let not_stopped: Vec<_> = ["+({ valueOf: mk })", "({})[{ toString: mk }]"] + .into_iter() + .filter(|read| { + let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_string_object_counts_its_flattened_rope_against_the_heap() { + // The same three ropes, each wrapped in a String object and read by index. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z'; \ + String.prototype.first = function () { return this[0]; };"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + // Wrapping alone reads no units, so it makes no copy. + let lengths = + Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length")); + assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0))); + for read in ["Object(X)[0]", "new String(X)[0]", "0 in Object(X)", "Object(X).hasOwnProperty(0)", "X.first()"] { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}"); + } + let wrapped = + Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()")); + assert_eq!(wrapped, Ok(JsValue::String("yzx".into()))); +} + +#[test] +fn a_string_object_takes_fuel_for_the_rope_it_flattens() { + let limits = Limits { fuel: 40_000, ..Limits::default() }; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object(s).length"; + assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); + for read in ["Object(s)[0]", "0 in new String(s)"] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {read}")); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{read}: {e:?}"); + } + // A run that ends with an error right after the read leaves the next run its whole fuel. + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 100_000, ..Limits::default() }); + let defined = realm.run("var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object.defineProperty(new String(s), 0, { value: 'a' })"); + assert!(matches!(defined, Err(ref e) if e.kind == RunErrorKind::Thrown), "{defined:?}"); + assert!(realm.run("for (var j = 0; j < 5000; j++) {} j").is_ok()); } #[test] fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() { - // Boxing the rope receiver of a non-strict function flattens it, and reserving the copy - // collects the garbage string `g` left. The callee and the argument object are off the - // stack by then, and must survive the collection. + // `g` leaves a garbage string, so the heap is close to its limit when the rope receiver of a + // non-strict function is boxed. The callee and the argument object are off the stack by + // then, and must survive anything boxing the receiver does. let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \ function mk() { var a = f('a'); a[0]; return a + 'b'; } 0"; let mut realm = Realm::with_options(Options::default(), Limits::default()); From 12617fac5e7fa3cd2696e4e206df1806ad0e118f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:39:46 -0800 Subject: [PATCH 088/126] Docs: put the HP-GL section back after the merge of main --- docs/mcp.md | 84 ++++++++++++++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index d4e41f5e69..4a73573b9b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6299,48 +6299,6 @@ or in `options` (`file.formatOptions {format}` lists them with their values): artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the active artboard of the `.ai` file it was opened from), else the first. A `.ai` file read from its editing copy opens on that artboard, and so does a `.ai` file restored from the native document it carries. -## HP-GL export - -`document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard -(or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard -(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: -a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so -fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a -gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A -symbol instance is drawn as its symbol's art with the art's effects, in the colors of the instance's stain. -Clipping masks cut the art they clip, and the art is cut at the artboard's edges. Hidden and non-printing layers, -hidden objects, objects at 0% opacity, template layers and guides are left out, and so are placed images, with a -warning. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner, or its -center with `origin: "center"`, rounded to whole units, as the HP-GL/2 and HP RTL Reference Guide defines them -([book listing](https://informit.com/store/hp-gl-2-and-hp-rtl-reference-guide-a-handbook-for-program-9780201310146); -the [vendor's support page](https://support.hp.com/sg-en/document/c01121263) names it as the language reference). -The guide also gives the instruction syntax and the encoding of `PE` numbers that the writer follows. Art more than -8,388,607 units (about 210 m) from the origin is cut off, with a warning. - -Each color is a pen. Pen 1 is the first color the plot draws in, pen 2 the next new one, and so on, up to 255 pens; -colors past those draw with the pen of the nearest color. The files of one export share the numbering, so a color is -the same pen in each file. `pens: ["#000000", "#ff0000"]` gives the pen table instead (pen 1 first): each path draws -with the pen of the nearest color, the lower pen when two are as near. A path's color is its topmost visible stroke's, -else its topmost visible fill's, where a fill or stroke at 0% opacity does not count. A gradient's color is the average -of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray -when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path -the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one -starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the -curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above -are VectorCraft's own. - -`hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and -ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's -paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names -type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke -looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping -work, symbols, live art, nesting depth and sorting. - -```json -{"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} -{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} -``` - File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel, and Save a Copy does too for a `.ai` file; Save, Save a Copy to a native file and Save as Template reuse the options as last saved (or Use Compression). @@ -6404,6 +6362,48 @@ instructions is refused with "not an HP-GL file". {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/part.plt","at":[300,200]}}} ``` +## HP-GL export + +`document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard +(or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard +(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: +a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so +fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a +gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A +symbol instance is drawn as its symbol's art with the art's effects, in the colors of the instance's stain. +Clipping masks cut the art they clip, and the art is cut at the artboard's edges. Hidden and non-printing layers, +hidden objects, objects at 0% opacity, template layers and guides are left out, and so are placed images, with a +warning. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner, or its +center with `origin: "center"`, rounded to whole units, as the HP-GL/2 and HP RTL Reference Guide defines them +([book listing](https://informit.com/store/hp-gl-2-and-hp-rtl-reference-guide-a-handbook-for-program-9780201310146); +the [vendor's support page](https://support.hp.com/sg-en/document/c01121263) names it as the language reference). +The guide also gives the instruction syntax and the encoding of `PE` numbers that the writer follows. Art more than +8,388,607 units (about 210 m) from the origin is cut off, with a warning. + +Each color is a pen. Pen 1 is the first color the plot draws in, pen 2 the next new one, and so on, up to 255 pens; +colors past those draw with the pen of the nearest color. The files of one export share the numbering, so a color is +the same pen in each file. `pens: ["#000000", "#ff0000"]` gives the pen table instead (pen 1 first): each path draws +with the pen of the nearest color, the lower pen when two are as near. A path's color is its topmost visible stroke's, +else its topmost visible fill's, where a fill or stroke at 0% opacity does not count. A gradient's color is the average +of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray +when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path +the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one +starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the +curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above +are VectorCraft's own. + +`hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and +ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's +paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names +type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke +looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping +work, symbols, live art, nesting depth and sorting. + +```json +{"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +``` + ## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one From c9ddbfe7cb74707db8fd0848bab7d117d258cd81 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:42:59 -0800 Subject: [PATCH 089/126] MCP: ui.dock targets, shortcut keys and native backslash paths keep hidden library ids - ui.dock's move takes a hidden panel's shown id in target, as the other docking destinations do. - Shortcut overrides keyed panel:<panel id>, saved and in an open Keyboard Shortcuts dialog, show the panel id shown for a library outside the read roots, also when nothing else refers to it. - Outside Windows, a native path is matched only as written: a sent /out/weird/name.svg no longer renames a hidden library at /out/weird\name.svg, and a reply does not read one as a spelling of the other. Windows paths are still matched with either separator. --- crates/ui-egui/src/control.rs | 56 ++++++++++++++++++-------- crates/ui-egui/src/control/tests.rs | 14 +++++++ crates/ui-egui/src/tests/automation.rs | 29 +++++++++++++ docs/mcp.md | 11 +++-- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9eb8650235..0074e5432d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -320,7 +320,8 @@ const PANEL_FIELDS: [&str; 7] = /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// [`PANEL_FIELDS`], the workspaces' layouts and the keys of shortcut overrides (`panel:<panel id>`, +/// saved and in an open Keyboard Shortcuts dialog), and by either in the `library` and `panel` params of /// recorded actions' steps. Text the person types (a command palette query, a workspace's or an /// action's name) is not read. The session's loaded libraries whose file the request may not read are /// hidden too. The ids are numbered as the library commands number them @@ -332,6 +333,12 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut rest); } + // A shortcut for showing a panel is keyed `panel:<id>`, saved and in an open Keyboard Shortcuts dialog. + for overrides in [&ui["shortcut_overrides"], &ui["dialog"]["fields"]["overrides"]] { + for panel in overrides.as_object().into_iter().flat_map(|o| o.keys()).filter_map(|key| key.strip_prefix("panel:")) { + collect_library_panel_ids(&json!(panel), &mut rest); + } + } collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { @@ -389,15 +396,18 @@ impl Hiding { } /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds - /// for the same path (written with either separator, relative or as the absolute path it reads as - /// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is. + /// for the same path (written with either separator when it is a Windows path + /// ([`splits_at_backslash`]), relative or as the absolute path it reads as ([`absolute_form`]), and + /// in any case of ASCII letters with `fold`) is shown as the sent path is. fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { let key = |id: &str| { let path = id.strip_prefix("loaded/").unwrap_or(id); - let id = absolute_form(path).unwrap_or_else(|| path.to_string()).replace('\\', "/"); - if fold { id.to_ascii_lowercase() } else { id } + let id = absolute_form(path).unwrap_or_else(|| path.to_string()); + let windows = splits_at_backslash(&id); + let id = if windows { id.replace('\\', "/") } else { id }; + (windows, if fold { id.to_ascii_lowercase() } else { id }) }; - let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); + let sent: Vec<(bool, String)> = pairs.iter().map(|(id, _)| key(id)).collect(); for (id, shown) in &mut self.hidden { if let Some(at) = sent.iter().position(|k| *k == key(id)) { shown.clone_from(&pairs[at].1); @@ -499,13 +509,21 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) { /// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of /// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with -/// the id shown, and the path in it with the file name shown, the path written with either separator -/// too, and as `{:?}` quotes it (each `\`, quote and control character escaped). A relative path is -/// also replaced as the absolute path the file access check reads it as, against the working folder. +/// the id shown, and the path in it with the file name shown, a Windows path ([`splits_at_backslash`]) +/// written with either separator too, and as `{:?}` quotes it (each `\`, quote and control character +/// escaped). A relative path is also replaced as the absolute path the file access check reads it as, +/// against the working folder. fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] } +/// Is `path` a Windows path, whose parts a `\` separates as well as a `/`: any path on Windows, and +/// elsewhere one that starts with a drive (`C:`) or a `\`? Outside Windows, a `\` in any other path is +/// part of a name. +fn splits_at_backslash(path: &str) -> bool { + cfg!(windows) || path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()) +} + /// The absolute path that `path`, a relative path (one that does not start with a separator, a drive /// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other /// path. @@ -525,10 +543,15 @@ fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { - let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); - let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); - let plain = [path, forward, back].map(|p| (p, name.to_string())); - for (path, name) in plain.into_iter().chain(debug) { + // A Windows path is also written with the other separator; outside Windows, a native + // path's `\` is part of a name, so that path is written only as it is. + let mut spellings = vec![path.clone()]; + if splits_at_backslash(&path) { + spellings.extend([path.replace('\\', "/"), path.replace('/', "\\")].into_iter().filter(|p| splits_at_backslash(p))); + } + let debug: Vec<_> = spellings.iter().map(|p| (quoted(p), quoted(name))).collect(); + let plain = spellings.into_iter().map(|p| (p, name.to_string())); + for (path, name) in plain.chain(debug) { for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { if !pairs.iter().any(|(f, _)| *f == from) { pairs.push((from, to)); @@ -1033,9 +1056,10 @@ fn modifiers(p: &Value) -> egui::Modifiers { /// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, /// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking -/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, -/// below}`) and a list of panels. -const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; +/// commands' destinations (`window.panel.move {anchor, before}`, `ui.dock {target}`, +/// `window.panel.float {onto, above, below}`) and a list of panels. Only a whole id is translated +/// ([`swap_whole_ids`]), so other text in these params stays. +const LIBRARY_PARAMS: [&str; 9] = ["panel", "library", "anchor", "target", "before", "onto", "above", "below", "panels"]; /// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is /// translated. diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 83d8b3ddbb..1fe6c44737 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -140,6 +140,20 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); } +/// Outside Windows, a `\` in a native path is part of a name: a sent path that differs from a hidden +/// library's path only by `/` in place of that `\` names another file, so the library keeps its id +/// and a reply does not read either path as a spelling of the other. +#[cfg(not(windows))] +#[test] +fn a_backslash_in_a_native_path_keeps_two_paths_apart() { + let mut hidden = hiding(&[(r"loaded//out/weird\name.svg", r"loaded/weird\name.svg")], &[]); + hidden.set_hidden(vec![("loaded//out/weird/name.svg".to_string(), "loaded/name.svg".to_string())], false); + assert_eq!(hidden.hidden[0].1, r"loaded/weird\name.svg", "{hidden:?}"); + let mut reply = json!({"error": r"no /out/weird\name.svg, /out/weird/name.svg or \out\weird\name.svg"}); + hide_under_a_root("native-backslash", &mut reply, &hidden); + assert_eq!(reply["error"], r"no weird\name.svg, name.svg or \out\weird\name.svg", "{reply}"); +} + /// A sent path whose text overlaps part of a hidden library's path does not keep that library's path /// from being hidden: the hidden libraries are matched first. #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 3b93e2396a..da94f4519f 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -977,6 +977,17 @@ fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { ); assert_eq!(r["ok"], true, "{r}"); assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // So does `ui.dock`'s move, whose destination is `target`. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); // An error that quotes the panel shows the id the client gave, and other text is not rewritten. let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); assert_eq!(r["ok"], false, "{r}"); @@ -986,6 +997,24 @@ fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { let _ = std::fs::remove_dir_all(base); } +/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard +/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library. +#[test] +fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() { + let (mut app, tx, base) = confined("shortcut-hidden-panel"); + let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg"))); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7"); + app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }}))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "panel:library:swatches:loaded/b.svg"; + let ui = &r["result"]["ui"]; + assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}"); + assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index e8e0bf79eb..4d804e3f97 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -140,10 +140,13 @@ the suite) keep the agent to the folders you give it: empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` - params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + params of a recorded action's steps in `ui.action_sets`, and the keys of shortcut overrides (`panel:<panel id>` + in `ui.shortcut_overrides` and an open Keyboard Shortcuts dialog's `ui.dialog.fields.overrides`), also when its + panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its - file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`), and a reply matches it only as written, not with `/` + in place of the `\`. When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest @@ -151,8 +154,8 @@ the suite) keep the agent to the folders you give it: file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id - shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, - `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `target`, `before`, `onto`, + `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query From f4945780728df86e34a953eb5b3e9d06699ceb06 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:45:06 -0800 Subject: [PATCH 090/126] MCP: effect.refreshDeferred takes objects in symbol, pattern, brush and mask art effect.deferredStatus lists a stage in the art of a symbol, pattern, brush or opacity mask by the id of the object in that art. effect.refreshDeferred took such an id only while the object still had a deferred stage; an existing object there without one was refused as no such object, while a layer object without a stage was accepted. An id is now valid when it names an object in the layers or in that art, an object without a stage adds nothing to the counts, and an id of no object is an error. The params text and docs/mcp.md said `id` names one object, but the params text did not list the key, so `{id: N}` was refused as an unknown parameter. It is now `{ids?|id?}`, and the command checks only the form of the ids before it looks them up itself. The requested ids are held in a set while the art is walked, and only those with a stage are handed to the refresh. --- crates/engine/src/cmd.rs | 17 +++++ crates/engine/src/cmd/effectcmd.rs | 34 +++++++--- crates/engine/src/session/execute.rs | 8 ++- crates/engine/src/tests/plugins.rs | 92 ++++++++++++++++++++++++++++ docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 6 files changed, 148 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b8c1f82e50..465025302f 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt Some(v) => Ok(Some(vec![checked_id(s, v, command)?])), } } +/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form: +/// for a command that looks its objects up itself, outside the document's layers too. +pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> { + if p.get("ids").is_some_and(|v| !v.is_null()) { + return ids_param(p, "ids", command); + } + match p.get("id").filter(|v| !v.is_null()) { + None => Ok(None), + Some(Value::String(r)) if r.starts_with('$') => { + Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) + } + Some(v) => { + let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?; + Ok(Some(vec![id])) + } + } +} /// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list. pub(crate) fn index(v: u64) -> usize { usize::try_from(v).unwrap_or(usize::MAX) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 30d927a966..9cadcc7876 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> { "Refresh Deferred Effects", ["Effect"], None, - "{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", + "{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", has_deferred, refresh_deferred ), @@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> None => rest.push(object), } } - if rest.is_empty() { + for (id, n) in definition_objects(d, &rest) { + out.entry(id).or_insert((n, false)); + } + out +} + +/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its +/// symbols, patterns and brushes, and opacity masks (in that art or in the layers). +fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap<u64, Arc<Node>> { + let mut out = std::collections::HashMap::new(); + if ids.is_empty() { return out; } + let ids: std::collections::HashSet<u64> = ids.iter().copied().collect(); let brushes = vectorcraft_brush::library(d); let symbols = d.symbols.iter().map(|s| &*s.art); let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)); let layers = d.layers.iter().map(|a| &**a); let mut look = |n: &Node| { - if rest.contains(&n.id.0) { - out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false)); + if ids.contains(&n.id.0) { + out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone())); } }; for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) { @@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> { let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone()); let ids = super::named_ids(p)?; let objects = prune(&doc, &fx); - if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) { - return Err(EngineError::NoNode(*id)); + // An object of the art drawn outside the layers is valid whether or not it has a stage. + let elsewhere: Vec<u64> = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect(); + let found = definition_objects(&doc, &elsewhere); + if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) { + return Err(EngineError::NoNode(NodeId(*id))); } - let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect()); + // Only the objects with a stage have anything to refresh. + let ids: Option<Vec<u64>> = ids.map(|ids| { + let mut ids: Vec<u64> = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }); let counts = fx.refresh(ids.as_deref(), |targets| { let mut renderer = vectorcraft_render::Renderer::new(); let mut whole = Vec::new(); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index abe9121eff..23079fe901 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -82,7 +82,13 @@ impl Session { && params.get("ids").is_none_or(Value::is_null) && params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$')); if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() { - cmd::given_objects(self, params, id)?; + // `effect.refreshDeferred` looks its objects up itself: they can also be in the art of + // a symbol, pattern, brush or opacity mask. + if id == "effect.refreshDeferred" { + cmd::given_object_ids(params, id)?; + } else { + cmd::given_objects(self, params, id)?; + } } if SELECTS_GIVEN_IDS.contains(&id) { self.check_given_ids(id, params)?; diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36dbc5ba8e..b1af923df4 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1153,6 +1153,98 @@ fn deferred_stages_in_symbol_art_are_listed_and_refreshed() { assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([])); } +/// `effect.refreshDeferred` takes the id of an object in the art of a symbol, pattern, brush or +/// opacity mask, as `effect.deferredStatus` lists it: one with a deferred stage is refreshed, and +/// one without (or whose stage is gone) adds nothing to the counts. The id of art that is no +/// longer in the document is an error. +#[test] +fn refresh_takes_objects_in_definition_art() { + let mut s = session(); + const FX: &str = "plugin.test.engine.deferred.nested"; + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.nested", "effect", "{}"), &out)); + // A symbol whose art holds an object with the effect and one without. + let staged = rect(&mut s, 10.0, "#ff0000"); + let plain = rect(&mut s, 30.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"ids": [staged.0], "effect": FX})); + run(&mut s, "select.set", json!({"ids": [staged.0, plain.0]})); + run(&mut s, "symbol.new", json!({"name": "Nested"})); + // A pattern, an art brush (its source deleted) and an opacity mask, none with a stage. + let tile = rect(&mut s, 50.0, "#00ff00"); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Tile", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let bar = rect(&mut s, 70.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [bar.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Bar"})); + run(&mut s, "edit.clear", json!({"ids": [bar.0]})); + let masked = rect(&mut s, 90.0, "#ff0000"); + let mask = rect(&mut s, 90.0, "#ffffff"); + run(&mut s, "select.set", json!({"ids": [masked.0, mask.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + // A layer object with a stage that stays deferred, and one without. + let kept = rect(&mut s, 130.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"ids": [kept.0], "effect": FX})); + let bare = rect(&mut s, 150.0, "#ff0000"); + + // The ids of the objects of `art` that are not in the layers. + let nested = |s: &Session, art: &vectorcraft_doc::Node| { + let d = &s.doc().unwrap().doc; + let mut ids = Vec::new(); + vectorcraft_doc::swatches::walk_with_masks(art, &mut |n| { + if d.node(n.id).is_none() { + ids.push(n.id.0); + } + }); + ids + }; + let d = s.doc().unwrap().doc.clone(); + let in_symbol = nested(&s, &d.symbols[0].art); + let in_pattern = d.patterns.iter().flat_map(|p| p.art.iter().flat_map(|a| nested(&s, a))).collect::<Vec<_>>(); + let brushes = vectorcraft_brush::library(&d); + let in_brush = brushes.iter().flat_map(|b| b.art().into_iter().flat_map(|a| nested(&s, a)).collect::<Vec<_>>()).collect::<Vec<_>>(); + let in_mask = d.node(masked).and_then(|n| n.extras().mask.as_ref()).map(|m| nested(&s, &m.art)).unwrap_or_default(); + for (what, ids) in [("symbol", &in_symbol), ("pattern", &in_pattern), ("brush", &in_brush), ("opacity mask", &in_mask)] { + assert!(!ids.is_empty(), "the {what} art has an object outside the layers"); + } + + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + let listed = |s: &mut Session| { + run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>() + }; + let deferred = listed(&mut s); + let [nested_staged, last] = deferred[..] else { panic!("two stages: {deferred:?}") }; + assert!(in_symbol.contains(&nested_staged) && last == kept.0, "{deferred:?} {in_symbol:?}"); + let Some(&nested_plain) = in_symbol.iter().find(|i| **i != nested_staged) else { panic!("{in_symbol:?}") }; + + // Objects without a stage, each on its own and together: nothing to refresh. + let none = json!({"refreshed": 0, "stillDeferred": 0, "failed": 0}); + let mut plain_ids = vec![nested_plain, in_pattern[0], in_brush[0], in_mask[0], bare.0]; + for id in &plain_ids { + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": id})), none, "object {id}"); + } + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), none); + assert_eq!(listed(&mut s), [nested_staged, kept.0]); + + // The staged object of the symbol's art, by its id, with the others. + step.store(0, std::sync::atomic::Ordering::Relaxed); + plain_ids.push(nested_staged); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + assert_eq!(listed(&mut s), [kept.0]); + // Its stage is gone; the object is still there. + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": nested_staged})), none); + + // Art no longer in the document, and an id of no object. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.clear(); + assert!(s.execute("effect.refreshDeferred", &json!({"id": in_pattern[0]})).is_err(), "the pattern is gone"); + assert!(s.execute("effect.refreshDeferred", &json!({"ids": [nested_plain, 999_999]})).is_err(), "not an object of the document"); + assert_eq!(listed(&mut s), [kept.0]); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..544e00a0a2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7975,13 +7975,15 @@ drawings leave a deferred stage as it is until its input or parameters change, w the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list when none is deferred). A stage whose object is deleted, or whose plug-in effect is -removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh +removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?|id?}` (Effect › Refresh Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them) again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn, and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that timed out, failed or have no plug-in. `id` names one object in place of -`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand +`ids`. An entry can name an object in the document's layers or in the art of a symbol, pattern, brush or opacity mask; +an object without a deferred stage adds nothing to the counts, and an entry that is not an object of the document is +an error. Each document has its own budget and its own deferred stages. Exports, Expand Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`): diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..4d096c6d41 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1708,6 +1708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings a failed removal can't move back:** when `plugin.remove` can't remove the grant and can't rename the plug-in's `.removed` folder back, its error names that folder, and the next load, install or removal of the plug-in renames it back instead of deleting it. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. From 27d63af68a0ce446c6375ead2d5390c419f2f1cb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:50:45 -0800 Subject: [PATCH 091/126] HP-GL export: insideFirst cuts the paths inside a shape before the shape `insideFirst: true` (default false) draws each pen's path only after the pen's paths that lie inside it: a letter's counter before the letter, a part's holes before the part. Without `sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw. Touching outlines count as inside; crossing ones, and copies of one outline, do not. Finding which paths lie inside which shares the plot's work budget with sorting, and each case that runs out of it adds a warning. Docs: docs/mcp.md (HP-GL export), docs/target-app-parity.md. --- crates/cad/src/hpgl/write.rs | 146 +++++++-- crates/cad/src/hpgl/write/nest.rs | 357 +++++++++++++++++++++ crates/cad/src/hpgl/write/tests.rs | 136 ++++++++ crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/hpgl.rs | 7 + crates/engine/src/cmd/fileio/tests_hpgl.rs | 22 +- docs/mcp.md | 19 +- docs/target-app-parity.md | 1 + 8 files changed, 654 insertions(+), 36 deletions(-) create mode 100644 crates/cad/src/hpgl/write/nest.rs diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 9e77495aa2..b38abd005c 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -16,15 +16,19 @@ //! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range //! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. //! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with -//! `sort`, each next path the one that starts nearest where the pen stopped. +//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`, +//! a pen draws a path only after every path of its own that lies inside it (a letter's counter +//! before the letter, a hole before the part). //! //! HP-GL writes `IN;`, then for each pen `PU;SP<n>;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`. //! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths //! as one encoded polyline (`PE`, base 64). mod clip; +mod nest; -use std::collections::HashSet; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; use std::sync::Arc; use kurbo::PathEl; @@ -47,7 +51,8 @@ pub const TOLERANCE_RANGE: std::ops::RangeInclusive<f64> = 0.01..=10.0; pub const MAX_PENS: usize = 255; /// The most points the plots of one export hold. const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 }; -/// The most point comparisons sorting may make, over the whole plot. +/// The most work sorting paths and finding which lie inside others may take, over the whole plot: +/// one for each point or box compared. const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 }; /// The most edge tests clipping masks may take, over the whole plot. const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 }; @@ -69,6 +74,8 @@ const MASK_POINTS: &str = "clipping masks past 10 million points together are le const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; +const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it"; +const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it"; const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out"; const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out"; @@ -144,6 +151,8 @@ pub struct HpglOptions { pub origin: Origin, /// Draw each pen's paths nearest first, instead of in paint order. pub sort: bool, + /// Draw a path only after the pen's paths that lie inside it. + pub inside_first: bool, /// The area plotted (document space): its bottom-left corner or center is the origin, and the /// art is cut at its edges. pub region: Rect, @@ -151,7 +160,15 @@ pub struct HpglOptions { impl Default for HpglOptions { fn default() -> Self { - Self { version: Version::default(), tolerance: DEFAULT_TOLERANCE, pens: vec![], origin: Origin::default(), sort: false, region: Rect::ZERO } + Self { + version: Version::default(), + tolerance: DEFAULT_TOLERANCE, + pens: vec![], + origin: Origin::default(), + sort: false, + inside_first: false, + region: Rect::ZERO, + } } } @@ -251,14 +268,10 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } - if opts.sort { + if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - let (stop, sorted) = nearest_first(paths, at, &mut work); - at = stop; - if !sorted { - warn(&mut warnings, UNSORTED); - } + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); } } let bytes = match opts.version { @@ -333,25 +346,50 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { dx * dx + dy * dy } -/// `paths` reordered from `at`: each next path is the one nearest where the pen stopped, by its -/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its -/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep -/// their order. Returns where the pen stops, and `false` when the work ran out. -fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) { - // Each path with its box. - let mut left: Vec<_> = std::mem::take(paths) - .into_iter() - .map(|p| { - let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]); - (p, b) - }) - .collect(); +/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest +/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or +/// for a closed path any of its points (where it then starts); without, the first in paint order. +/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. +/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with +/// `inside_first`, with a warning. Returns where the pen stops. +fn reorder( + paths: &mut Vec<Vec<(i64, i64)>>, + mut at: (i64, i64), + work: &mut u64, + nearest: bool, + inside_first: bool, + warnings: &mut Vec<String>, +) -> (i64, i64) { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); + // The paths each path lies inside, and how many paths inside each are still to draw. + let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); + if inside_first && outer.is_none() { + warn(warnings, UNNESTED); + } + let mut waiting = vec![0usize; paths.len()]; + for o in outer.iter().flatten().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w += 1; + } + } + if !nearest { + if let Some(outer) = &outer { + let all = std::mem::take(paths).into_iter().enumerate().collect(); + inner_first(all, outer, &mut waiting, paths); + } + return at; + } + // Each path with its index and box. + let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect(); while !left.is_empty() { // (index, reversed, the closed path's point to start at, distance) - let mut best: (usize, bool, Option<usize>, i128) = (0, false, None, i128::MAX); - for (i, (p, b)) in left.iter().enumerate() { + let mut best: (usize, bool, Option<usize>, i128) = (usize::MAX, false, None, i128::MAX); + for (i, (j, p, b)) in left.iter().enumerate() { let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; *work = work.saturating_sub(1); + if waiting.get(*j).is_some_and(|w| *w > 0) { + continue; + } if s == e && p.len() > 2 { if box_dist2(at, *b) >= best.3 { continue; @@ -372,11 +410,27 @@ fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mu best = (i, true, None, de); } } - if *work == 0 { - paths.extend(left.into_iter().map(|(p, _)| p)); - return (at, false); + // Spent, or no path free to draw (which only a fault could cause): the rest in paint + // order, each after the paths inside it. + if *work == 0 || best.0 >= left.len() { + match &outer { + Some(outer) => { + warn(warnings, UNSORTED_INSIDE); + inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); + } + None => { + warn(warnings, UNSORTED); + paths.extend(left.into_iter().map(|(_, p, _)| p)); + } + } + return at; + } + let (j, mut p, _) = left.remove(best.0); + for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w = w.saturating_sub(1); + } } - let (mut p, _) = left.remove(best.0); if best.1 { p.reverse(); } else if let Some(k) = best.2 { @@ -393,7 +447,39 @@ fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mu } paths.push(p); } - (at, true) + at +} + +/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order, +/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting` +/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside +/// larger ones, so every path comes free; a path left waiting by a fault is drawn last. +fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec<usize>], waiting: &mut [usize], paths: &mut Vec<Vec<(i64, i64)>>) { + let mut held: Vec<Option<Vec<(i64, i64)>>> = (0..outer.len()).map(|_| None).collect(); + let mut free = BinaryHeap::new(); + for (j, p) in left { + if let Some(h) = held.get_mut(j) { + if waiting.get(j).is_none_or(|w| *w == 0) { + free.push(Reverse(j)); + } + *h = Some(p); + } else { + paths.push(p); + } + } + while let Some(Reverse(j)) = free.pop() { + let Some(p) = held.get_mut(j).and_then(Option::take) else { continue }; + paths.push(p); + for &o in outer.get(j).into_iter().flatten() { + if let Some(w) = waiting.get_mut(o) { + *w = w.saturating_sub(1); + if *w == 0 { + free.push(Reverse(o)); + } + } + } + } + paths.extend(held.into_iter().flatten()); } /// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`. diff --git a/crates/cad/src/hpgl/write/nest.rs b/crates/cad/src/hpgl/write/nest.rs new file mode 100644 index 0000000000..eee4630ff3 --- /dev/null +++ b/crates/cad/src/hpgl/write/nest.rs @@ -0,0 +1,357 @@ +//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the +//! inner paths first. + +/// The box of `p`: `[x0, y0, x1, y1]`. +pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] { + p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]) +} + +/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of +/// its segments crosses the closed path's line, each part of it between the points where it meets +/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one +/// part lies inside. A path touching the line of the one around it is inside it, a path crossing +/// it is not, and copies of one outline are not inside each other. A path is tested only against +/// closed paths larger than it (by the area of their box, then the area they enclose, then paint +/// order), so no two paths are each inside the other, and only against the closed paths in the +/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a +/// box compared cost one each, and each segment tested costs the closed path's points for its +/// crossings and again for each part of it. +pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option<Vec<Vec<usize>>> { + let size: Vec<(i128, i128)> = paths + .iter() + .zip(boxes) + .map(|(p, b)| { + let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1])); + (box_area, if closed(p) { twice_area(p) } else { 0 }) + }) + .collect(); + let rings: Vec<usize> = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect(); + let mut out = vec![vec![]; paths.len()]; + if rings.is_empty() { + return Some(out); + } + let grid = Grid::new(&rings, boxes, work)?; + for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() { + for &r in grid.at(ab[0], ab[1]) { + spend(work, 1)?; + let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue }; + let smaller = (size.get(a), a) < (size.get(r), r); + if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] { + continue; + } + if inside(p, ring, work)? + && let Some(o) = out.get_mut(a) + { + o.push(r); + } + } + } + Some(out) +} + +/// The most cells along each side of a [`Grid`]. +const MAX_GRID_SIDE: usize = 512; +/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed +/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more +/// entries than the pen has points. +const MAX_CELLS_PER_PATH: usize = 4; + +/// A grid over the boxes of a pen's closed paths, about one cell for each, up to +/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers +/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed +/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell. +struct Grid { + /// The first corner of the closed paths' boxes together. + x0: i128, + y0: i128, + /// Their width and height, plus one. + w: i128, + h: i128, + /// Cells along each side. + side: usize, + /// The cells row by row. + cells: Vec<Vec<usize>>, + /// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells. + wide: Vec<usize>, +} + +impl Grid { + /// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent. + fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option<Self> { + let all = rings + .iter() + .filter_map(|&r| boxes.get(r)) + .fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]); + let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE); + let (x0, y0) = (i128::from(all[0]), i128::from(all[1])); + let mut grid = Self { + x0, + y0, + w: (i128::from(all[2]) - x0 + 1).max(1), + h: (i128::from(all[3]) - y0 + 1).max(1), + side, + cells: vec![vec![]; side * side], + wide: vec![], + }; + for &r in rings { + let Some(b) = boxes.get(r) else { continue }; + let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue }; + let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if covered > MAX_CELLS_PER_PATH { + spend(work, 1)?; + grid.wide.push(r); + continue; + } + spend(work, covered as u64)?; + for cy in cy0..=cy1 { + for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() { + cell.push(r); + } + } + } + Some(grid) + } + + /// The column and row of the cell holding `(x, y)`, `None` outside the grid. + fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> { + let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0); + if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h { + return None; + } + let side = self.side as i128; + Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize)) + } + + /// The wide closed paths, then those listed in the cell holding `(x, y)`. + fn at(&self, x: i64, y: i64) -> impl Iterator<Item = &usize> { + let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice); + self.wide.iter().chain(cell) + } +} + +/// Takes `n` from `work`; `None`, with `work` at 0, when it has less. +fn spend(work: &mut u64, n: u64) -> Option<()> { + let left = work.checked_sub(n); + *work = left.unwrap_or(0); + left.map(|_| ()) +} + +/// Does `p` start and end at one point, with three other points or more? +fn closed(p: &[(i64, i64)]) -> bool { + p.len() > 3 && p.first() == p.last() +} + +/// Twice the area the closed path `p` encloses. +fn twice_area(p: &[(i64, i64)]) -> i128 { + let sum: i128 = p + .windows(2) + .map(|w| match w { + [a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1), + _ => 0, + }) + .sum(); + sum.abs() +} + +/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent. +fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option<bool> { + let mut any = false; + for w in p.windows(2) { + let [s, e] = w else { continue }; + spend(work, ring.len() as u64)?; + // Where the segment meets the line without crossing it: its ends and the ring's points on + // it. Between two of these the segment is all inside or all outside. + let mut stops = vec![*s, *e]; + for edge in ring.windows(2) { + let [a, b] = edge else { continue }; + if crosses((*s, *e), (*a, *b)) { + return Some(false); + } + if a != s && a != e && on((*s, *e), *a) { + stops.push(*a); + } + } + let along = |q: &(i64, i64)| { + (i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0)) + + (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1)) + }; + stops.sort_by_key(along); + stops.dedup(); + for part in stops.windows(2) { + let [m, n] = part else { continue }; + spend(work, ring.len() as u64)?; + // The part's middle, doubled so it is whole. + match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) { + Some(false) => return Some(false), + Some(true) => any = true, + None => {} + } + } + } + Some(any) +} + +/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it. +fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 { + let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1)) + - (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0)); + d.signum() +} + +/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other +/// to the other side? +fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool { + side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0 +} + +/// Does `q` lie on segment `u`? +fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool { + let ((x0, y0), (x1, y1)) = u; + side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1) +} + +/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the +/// even-odd rule, `Some(false)` outside, `None` on its line. +fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option<bool> { + let mut inside = false; + for w in ring.windows(2) { + let [a, b] = w else { continue }; + let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1))); + // Which side of the segment's line `q` is on, 0 on it. + let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0); + if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) { + return None; + } + // A segment crossing the level of `q` to its right. + if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) { + inside = !inside; + } + } + Some(inside) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> { + vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + } + + fn nest(paths: &[Vec<(i64, i64)>]) -> Option<Vec<Vec<usize>>> { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = u64::MAX; + outer(paths, &boxes, &mut work) + } + + #[test] + fn points_inside_outside_and_on_the_line() { + let ring = square(0, 0, 10, 10); + assert_eq!(locate((10, 10), &ring), Some(true)); + assert_eq!(locate((30, 10), &ring), Some(false)); + assert_eq!(locate((10, -2), &ring), Some(false)); + assert_eq!(locate((20, 10), &ring), None); + assert_eq!(locate((0, 0), &ring), None); + } + + #[test] + fn a_path_inside_another_lies_inside_it_and_not_the_other_way() { + let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]])); + } + + #[test] + fn touching_counts_as_inside_and_crossing_does_not() { + // A square with a corner on the outline's left edge, a triangle whose corners lie on the + // outline's edges, and a square that crosses the outline. + let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]])); + } + + /// Every point of a copy lies on the other's line, and none inside it. + #[test] + fn copies_of_one_outline_do_not_lie_inside_each_other() { + let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]])); + } + + /// A line whose ends and middle lie inside an outline but which crosses a slot cut into it. + #[test] + fn a_line_across_a_slot_is_not_inside() { + let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + } + + /// A line that leaves an outline and comes back through two of its corners, crossing none of + /// its sides, and one that runs along the bottom of a slot. + #[test] + fn a_line_through_corners_is_inside_only_where_it_stays_inside() { + let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]])); + } + + /// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its + /// lobes lies inside it. + #[test] + fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() { + let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)]; + assert_eq!(twice_area(&bow), 0); + assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]])); + } + + #[test] + fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() { + let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)]; + assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]])); + } + + /// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths + /// near it, not against every part. + #[test] + fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() { + let mut paths = vec![]; + for row in 0..100 { + for col in 0..100 { + let (x, y) = (col * 30, row * 30); + paths.push(square(x, y, x + 20, y + 20)); + paths.push(square(x + 5, y + 5, x + 15, y + 15)); + } + } + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 5_000_000; + let found = outer(&paths, &boxes, &mut work); + let want: Vec<Vec<usize>> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect(); + assert_eq!(found, Some(want)); + } + + /// 400 closed paths that each cover most of the grid are listed once each, not in every cell + /// they cover. + #[test] + fn closed_paths_covering_many_cells_are_listed_once() { + let mut paths: Vec<Vec<(i64, i64)>> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect(); + paths.extend((0..400).map(|i| { + let (x, y) = ((i % 20) * 50, (i / 20) * 50); + square(x, y, x + 10, y + 10) + })); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let rings: Vec<usize> = (0..paths.len()).collect(); + let mut work = u64::MAX; + let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") }; + assert_eq!(grid.wide, (0..400).collect::<Vec<_>>()); + assert!(grid.cells.iter().map(Vec::len).sum::<usize>() <= 400 * MAX_CELLS_PER_PATH); + } + + #[test] + fn finding_what_lies_inside_stops_at_its_budget() { + let paths: Vec<Vec<(i64, i64)>> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect(); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 1_000; + assert_eq!(outer(&paths, &boxes, &mut work), None); + assert_eq!(work, 0); + let mut work = u64::MAX; + let found = outer(&paths, &boxes, &mut work); + assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::<usize>()), Some(50 * 49 / 2)); + } +} diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 2093fe8b48..6360cebdaf 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -819,6 +819,142 @@ fn sorting_stops_at_its_budget_and_keeps_every_path() { assert_eq!(text(&out).matches("PD").count(), 600); } +/// `x` points in plotter units. +fn plu(x: f64) -> i64 { + (x * 1016.0 / 72.0).round() as i64 +} + +/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units). +fn lefts(out: &HpglOutput) -> Vec<i64> { + let mut lefts: Vec<i64> = vec![]; + for i in text(out).split_terminator(';') { + let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::<i64>().ok()); + match i.get(..2) { + Some("PU") => lefts.extend(xs.min()), + Some("PD") => { + if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) { + *l = (*l).min(x); + } + } + _ => {} + } + } + lefts +} + +/// A letter O made as one path: its outline, then its counter. +#[test] +fn inside_first_cuts_a_letters_counter_before_its_outline() { + let d = doc_with(|d| { + let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath(); + bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath()); + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]); +} + +/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`. +#[test] +fn a_part_with_two_holes_is_cut_holes_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK), + ] + }); + let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0)); + assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]); + // Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest + // hole comes first, then the other, then the part. + assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]); + let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&both), [left, right, part]); + assert!(both.warnings.is_empty(), "{:?}", both.warnings); +} + +/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place +/// in paint order. +#[test] +fn nested_rings_are_cut_innermost_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK), + stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]); +} + +/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with +/// the red pen, after the black part. +#[test] +fn inside_first_orders_each_pens_paths() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)), + stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]); +} + +/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in +/// paint order, with a warning. +#[test] +fn inside_first_past_its_budget_draws_in_paint_order() { + let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED]); + let l = lefts(&out); + assert_eq!(l.len(), 100); + assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); + // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(lefts(&out), l); +} + +/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and +/// then the outer one last. +#[test] +fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)]; + v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNSORTED_INSIDE]); + let l = lefts(&out); + assert_eq!(l.len(), 602); + assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); +} + +/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them, +/// within the budget. +#[test] +fn inside_first_alone_keeps_paint_order_for_many_paths() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)]; + v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let l = lefts(&out); + assert_eq!(l.len(), 1001); + assert_eq!(l.last(), Some(&plu(0.5))); + assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}"); +} + #[test] fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() { let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index b518478464..d56e327228 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index 0016afa10e..154c9bce1d 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -48,6 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "false", description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order", }, + FormatOption { + name: "insideFirst", + ty: "boolean", + default: "false", + description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next", + }, ]; /// The HP-GL options in `p` (the region is set per plot). @@ -75,6 +81,7 @@ fn parse(p: &Value) -> Result<HpglOptions> { o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?; } o.sort = switch_or(p, "sortPaths", false, C)?; + o.inside_first = switch_or(p, "insideFirst", false, C)?; Ok(o) } diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 1cb4c03b83..f4767fd81d 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -102,7 +102,7 @@ fn the_dialogs_and_the_format_list_name_hpgl() { let r = Session::new().execute("document.formats", &json!({})).unwrap(); let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false))); - for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths"] { + for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] { assert!(f["options"].get(o).is_some(), "{o}: {f}"); } assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl"); @@ -184,6 +184,7 @@ fn export_options_are_checked() { (json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"), (json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"), (json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"), + (json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"), ]; for (p, want) in cases { let e = refused(&mut s, p.clone()); @@ -198,6 +199,25 @@ fn export_options_are_checked() { assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;"); } +/// `insideFirst` draws the square inside the other one first, though it was made last. +#[test] +fn inside_first_draws_the_inner_square_first() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap(); + let starts = |s: &mut Session, p: Value| { + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + (plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap()) + }; + let (outer, inner) = starts(&mut s, json!({"format": "hpgl"})); + assert!(outer < inner); + let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true})); + assert!(inner < outer); +} + /// Export Selection plots the selected square alone (the red one, made last), from the /// bottom-left corner of its bounds. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 4a73573b9b..ce29408fed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6388,20 +6388,31 @@ else its topmost visible fill's, where a fill or stroke at 0% opacity does not c of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one -starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the -curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above -are VectorCraft's own. +starts at its point nearest the pen). With `insideFirst: true`, a path is drawn only after every path of the same +pen that lies inside it, for example a letter's counter before the letter and a part's holes before the part. A +path lies inside a closed path when it does not cross that path's line, every part of it lies inside that path (by +the even-odd rule) or on its line, and some part lies inside: a path touching the line around it is inside, a path +crossing it or leaving it through a corner is not, and copies of one outline are not inside each other. Without +`sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw. +Curves become straight moves no further than `curveTolerance` points from the curve (default 0.1, from 0.01 to 10). +These defaults, the 255-pen limit and the rules for pen numbers, colors and the order of paths above are +VectorCraft's own. `hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping -work, symbols, live art, nesting depth and sorting. +work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one +work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet +drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is +finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add +a warning. ```json {"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/stencil.plt","sortPaths":true,"insideFirst":true}}} ``` ## TIFF, BMP and Targa export diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9265079ea..e4fa67b0eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1508,6 +1508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). From 78e8957f717343269e0a8dcaa5e6ad6e6b3d38b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:47:44 -0800 Subject: [PATCH 092/126] Data Recovery: a removal that Windows reports as missing names a file in the way On Windows, removing a copy whose path runs through a file reports the copy as missing, so the removal counted as done and the file in the way went unreported, while a listing names it. The removal now names that file, and a copy that is just not there still counts as removed. --- crates/engine/src/cmd/recovery.rs | 19 +++++++++++++++---- crates/engine/src/tests/recovery.rs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 136ba51ceb..7f5f33e34b 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -316,6 +316,20 @@ impl FolderStore { } } + /// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a + /// file is on the way to it: Windows reports that as a missing file, and it is named as for a + /// listing. + pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> { + match r { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) { + Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())), + _ => Ok(()), + }, + Err(e) => Err(Self::file_error(f, &e)), + Ok(()) => Ok(()), + } + } + /// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix) /// belongs to this account. Listing areas leaves links out, so a name another account had /// linked to a folder of its own in a shared recovery folder looked free, and claiming it @@ -453,10 +467,7 @@ impl RecoveryStore for FolderStore { } fn remove(&self, name: &str) -> std::result::Result<(), String> { let f = self.file(name)?; - match std::fs::remove_file(&f) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Self::file_error(&f, &e)), - _ => Ok(()), - } + Self::removed(&f, std::fs::remove_file(&f)) } fn location(&self) -> String { self.0.to_string_lossy().into_owned() diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 2b991e3320..0ba51f20a3 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -514,6 +514,24 @@ fn folder_errors_name_a_file_in_the_way() { assert!(wrong.is_empty(), "{wrong:?}"); } +/// A removal that finds the copy missing because a file is on the way to it (how Windows reports a +/// file in the way) names that file. A copy that is just not there still counts as removed. +#[test] +fn a_removal_reported_missing_names_a_file_in_the_way() { + let base = std::env::temp_dir().join(format!("vc-recovery-remove-in-way-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let file = base.join("file"); + std::fs::write(&file, b"").unwrap(); + let missing = || std::io::Error::from(std::io::ErrorKind::NotFound); + let behind = FolderStore::removed(&file.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing())); + let gone = FolderStore::removed(&base.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing())); + let _ = std::fs::remove_dir_all(&base); + let named = format!("{} is not a folder: ", file.display()); + assert!(behind.as_ref().is_err_and(|e| e.starts_with(&named)), "{behind:?}"); + assert_eq!(gone, Ok(())); +} + /// A folder of the store's path that is missing and can't be made: a write or a lock names it as /// the folder it couldn't make, and a read names it as missing. An area folder that can't be made /// in a store folder that is there is named the same way. From 40283f4a380955ac22639e31bcd6354c5931b75c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:49:15 -0800 Subject: [PATCH 093/126] Scripts: loose equality and the JSON space string keep a converted string rooted while it is flattened --- crates/js/src/builtins/json.rs | 3 ++- crates/js/src/interp.rs | 4 +++- crates/js/src/text_tests.rs | 19 ++++++++++++------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 364ec99696..d32344946e 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -474,7 +474,8 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Se let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; s.gap = vec![u16::from(b' '); n]; } - Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), + // Rooted, so what the string holds is still counted while its copy is reserved. + Value::String(t) => s.gap = interp.rooted(&[Value::String(t.clone())], |i| flat_charged(i, &t))?.iter().take(10).copied().collect(), _ => {} } Ok(s) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 19d2eef22c..b399ebc237 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2816,7 +2816,9 @@ impl Interp { | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { - return Ok(*n == string_to_number(&flat_charged(self, s)?)); + // Rooted, so what the string holds is still counted while its copy is reserved. + let units = self.rooted(&[x.clone(), y.clone()], |i| flat_charged(i, s))?; + return Ok(*n == string_to_number(&units)); } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 3d3455544e..6fccc085fd 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -361,13 +361,18 @@ fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ function mk() { var a = f('a'); a[0]; return a + 'b'; }"; let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; - let not_stopped: Vec<_> = ["+({ valueOf: mk })", "({})[{ toString: mk }]"] - .into_iter() - .filter(|read| { - let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); - !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) - }) - .collect(); + let not_stopped: Vec<_> = [ + "+({ valueOf: mk })", + "({})[{ toString: mk }]", + "({ valueOf: mk }) == 1", + "JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())", + ] + .into_iter() + .filter(|read| { + let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } From f388a795ce2b7c6486e772a085d206d71783a294 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:49:43 -0800 Subject: [PATCH 094/126] Script checker: a private name that starts with a directive's name is read to its end Inside braces, a line such as `#include$size = 1` was taken for an `#include` line, because only the ASCII letters, digits and `_` after the `#` were compared with the directive names. The name now runs over every character a name may hold, written out or as an escape, before the comparison. --- crates/scriptcheck/src/lex.rs | 14 ++++++++++++-- crates/scriptcheck/src/tests.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 02d8c2de03..a9a805d87c 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -457,10 +457,20 @@ impl<'s> Lexer<'s> { /// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line: /// the line is inside braces or in the code of an XML literal, and the name `rest` starts with - /// is not a directive's. + /// is not a directive's. The name runs over every character a name may hold, written out or as + /// an escape, so `#include$size` is not `#include`. pub fn private_line(&self, rest: &str) -> bool { let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty(); - let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()); + let mut end = 0; + while let Some(more) = rest.get(end..) { + let len = match more.chars().next() { + Some('\\') => escape(more).filter(|&(c, _)| ident_part(c)).map(|(_, len)| len), + Some(c) if ident_part(c) => Some(c.len_utf8()), + _ => None, + }; + let Some(len) = len else { break }; + end += len; + } let name = rest.get(..end).unwrap_or_default(); in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name)) } diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 19cee61603..853f212c11 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -491,6 +491,21 @@ fn private_name_at_line_start_is_not_a_directive() { ); } +#[test] +fn private_name_that_starts_with_a_directive_name_is_not_a_directive() { + let r = report(concat!( + "class Box {\n", + " #include$size = 1;\n", + " #include\u{e9} = 2;\n", + " #include\\u0041 = 3;\n", + " #include\\u{41} = 4;\n", + " #INCLUDE 'a.jsxinc'\n", + "}\n", + )); + let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect(); + assert_eq!(directives, [("include", 6)]); +} + #[test] fn xml_code_uses_escaped_names() { let r = report("var x = <a>{\\u{61}pp.activeDocument}</a>;\nvar y = <b>\\u0061pp.z \\u{61}</b>;\n"); From c2c560eeaaf9ff7d4ad36df2dcde0ca46a4ca19f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:57:35 -0800 Subject: [PATCH 095/126] Scripts: a String object's character is read along its rope when the copy does not fit When the flattened copy of a String object's concatenated string would pass the heap limit before a collection can run, the indexed read walks the rope to the unit instead, taking a unit of fuel for each node it visits, so no copy is made. --- crates/js/src/interp.rs | 38 +++++++++++++++++++++--------- crates/js/src/text_tests.rs | 46 ++++++++++++++++++++----------------- crates/js/src/value.rs | 28 ++++++++++++++++++++++ 3 files changed, 80 insertions(+), 32 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b399ebc237..696df47784 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -209,7 +209,8 @@ pub(crate) struct Interp { /// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String /// object, which the heap's total adds before the next limit check. flattened: std::cell::Cell<usize>, - /// Fuel for the units those copies took, which the next step takes. + /// Fuel for the units those copies took and the rope nodes it read in place, which the next + /// step takes. pub(crate) flattened_fuel: std::cell::Cell<u64>, /// The global code's default XML namespace URI (ECMA-357 section 12.1). pub(crate) xml_default_uri: Key, @@ -1246,18 +1247,33 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - if s.is_rope() { - // Flattening copies the units. The run ends here when the fuel for the - // copy is not left; otherwise the next step takes it, and the next limit - // check adds the copy's bytes. - let fuel = self.flattened_fuel.get().saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); - if fuel > self.fuel { - return Err(Abrupt::Limit(LimitKind::Fuel)); + let unit = if s.is_rope() { + let owed = self.flattened_fuel.get(); + let flattened = self.flattened.get().saturating_add(string_bytes(s.len())); + if self.heap.bytes.saturating_add(flattened) > self.limits.heap_bytes { + // The copy does not fit without a collection, which needs `&mut self`: + // the unit is read along the rope instead, a unit of fuel per node. + let Some((unit, visited)) = s.unit_at(i as usize, self.fuel.saturating_sub(owed)) else { + return Err(Abrupt::Limit(LimitKind::Fuel)); + }; + self.flattened_fuel.set(owed.saturating_add(visited)); + unit + } else { + // Flattening copies the units. The run ends here when the fuel for + // the copy is not left; otherwise the next step takes it, and the + // next limit check adds the copy's bytes. + let fuel = owed.saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); + if fuel > self.fuel { + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.flattened_fuel.set(fuel); + self.flattened.set(flattened); + s.flat().get(i as usize).copied() } - self.flattened_fuel.set(fuel); - self.flattened.set(self.flattened.get().saturating_add(string_bytes(s.len()))); + } else { + s.flat().get(i as usize).copied() } - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + .unwrap_or(0); return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE })); } } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 6fccc085fd..d2627cfb58 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -314,23 +314,15 @@ fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; - let not_stopped: Vec<_> = [ - "X < 'a'", - "X == 1", - "+X", - "X[0]", - "new String(X)[0]", - "({})[{ toString: function () { return X; } }]", - "JSON.stringify(0, null, X)", - "[1].join(X)", - ] - .into_iter() - .filter(|read| { - let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); - let result = Realm::with_options(Options::default(), limits.clone()).run(&src); - !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) - }) - .collect(); + let not_stopped: Vec<_> = + ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } @@ -386,10 +378,22 @@ fn a_string_object_counts_its_flattened_rope_against_the_heap() { let lengths = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length")); assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0))); - for read in ["Object(X)[0]", "new String(X)[0]", "0 in Object(X)", "Object(X).hasOwnProperty(0)", "X.first()"] { - let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); - let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); - assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}"); + // Two copies fit. The third read finds no room for its copy and reads the unit along the + // rope, so the live heap stays within the limit. + for (read, expected) in [ + ("Object(X)[0]", "xxx"), + ("new String(X)[0]", "xxx"), + ("0 in Object(X)", "truetruetrue"), + ("Object(X).hasOwnProperty(0)", "truetruetrue"), + ("X.first()", "xxx"), + ("Object(X)[524288]", "undefinedyz"), + ] { + let src = format!("{build} '' + ({}) + ({}) + ({})", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let mut realm = Realm::with_options(Options::default(), limits.clone()); + assert_eq!(realm.run(&src), Ok(JsValue::String(expected.into())), "{read}"); + let interp = realm.interp(); + interp.collect_garbage(); + assert!(interp.heap.bytes <= limits.heap_bytes, "{read}: {} bytes live", interp.heap.bytes); } let wrapped = Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()")); diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index e164001ad9..a0fbcd4a53 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -163,6 +163,34 @@ impl Str { flat } + /// The code unit at `i` read along the rope without flattening it, visiting at most + /// `max_nodes` nodes: `None` when that is not enough, otherwise the unit (when `i` is in + /// range) and the number of nodes visited. + pub(crate) fn unit_at(&self, mut i: usize, max_nodes: u64) -> Option<(Option<u16>, u64)> { + let mut node = self.clone(); + let mut visited = 0u64; + loop { + visited += 1; + if visited > max_nodes { + return None; + } + let next = match &*node.0.borrow() { + Node::Flat(f) => return Some((f.get(i).copied(), visited)), + Node::Concat { left, right, .. } => { + let left_len = left.len(); + if i < left_len { + left.clone() + } else { + i -= left_len; + right.clone() + } + } + Node::Taken => return Some((None, visited)), + }; + node = next; + } + } + pub(crate) fn to_rust_string(&self) -> String { String::from_utf16_lossy(&self.flat()) } From 280ca031b57d75ee743a8c7db86af30f03891813 Mon Sep 17 00:00:00 2001 From: Hamza <mail.hamzanaveed@gmail.com> Date: Thu, 12 Nov 2026 11:18:00 +0500 Subject: [PATCH 096/126] Stroke options on a group with its own stroke edit that stroke stroke.set without an item always went to the painted leaves, so a join set on a group whose stroke is its own (a row in the group's stack) left that stroke alone and gave every member without a stroke a new black 1 pt one: on a PDF map, every glyph path got an outline. A selected group or layer with a stroke row of its own now edits that row, as the Stroke panel does with the group targeted; other targets reach their leaves as before. A stroke row made for options alone (cap, join, miter limit, dashes, alignment) has no paint, so nothing becomes visible; a weight, an arrowhead or a profile still gives the black stroke. Fixes #6739 --- crates/engine/src/cmd/stroke.rs | 28 +++++++++++++++-- crates/engine/src/tests/appearance.rs | 44 ++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index e97659bfc9..4153fedd51 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -206,11 +206,31 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { ids.is_empty() && p.get("ids").is_none() && p.get("id").is_none() } +/// The objects `stroke.set` edits with no `item`: a selected group or layer with a stroke row of +/// its own edits that row, as the Stroke panel does with the group targeted (#6739); the other +/// targets reach their painted leaves, as before. +fn own_stroke_or_leaves(s: &Session, p: &Value) -> Result<Vec<vectorcraft_doc::NodeId>> { + let picked = super::targets(s, p)?; + let d = &s.doc()?.doc; + let own = |id: &vectorcraft_doc::NodeId| { + d.node(*id).is_some_and(|n| { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.is_compound_shape() && n.appearance.stroke().is_some() + }) + }; + let (mut out, rest): (Vec<_>, Vec<_>) = picked.into_iter().partition(own); + for id in super::leaf_targets(s, &rest)? { + if !out.contains(&id) { + out.push(id); + } + } + Ok(out) +} + fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; let item = item_target(s, p, C)?.of_kind(s, false); - let mut ids = item.targets(s, p)?; + let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? }; let new_art = no_targets(&ids, p); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. @@ -261,7 +281,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { return Ok(()); } if index.is_none() && n.appearance.stroke().is_none() { - n.appearance.set_stroke(Paint::solid(Color::BLACK)); + // Options alone (cap, join, miter limit, dashes, alignment) don't make a stroke + // visible: the row made for them has no paint. A weight, an arrowhead or a profile + // still gives the object the black stroke the panel draws (#6739). + let visible = change.weight.is_some() || sa.is_some() || ea.is_some() || profile.is_some(); + n.appearance.set_stroke(if visible { Paint::solid(Color::BLACK) } else { Paint::None }); } if let Some(st) = n.appearance.stroke_at_mut(index) { set(st); diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 8d12c83719..53cbb42ca9 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; -use vectorcraft_doc::{AppearanceItem, FillLayer, Node}; +use vectorcraft_doc::{AppearanceItem, FillLayer, LineJoin, Node}; use crate::*; @@ -185,6 +185,48 @@ fn group_rows_are_the_groups_own_stack() { assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); } +/// #6739: `stroke.set` without an item on a group with a stroke row of its own edits that row; +/// its members, which have no stroke (as paths a PDF import brings in), are left alone instead of +/// each getting a black 1 pt stroke. Options alone never make a stroke visible: a row created for +/// a join has no paint, while a weight still gives the usual black stroke. +#[test] +fn stroke_options_on_a_group_with_its_own_stroke_edit_that_stroke() { + let mut s = session(); + let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 250.0)); + for id in [a, b] { + run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1})); + assert!(node(&s, id).appearance.stroke().is_none(), "the member has no stroke row"); + } + let g = NodeId(run(&mut s, "object.group", json!({"ids": [a.0, b.0]}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({"ids": [g.0]})); + run(&mut s, "appearance.setItem", json!({"ids": [g.0], "index": 0, "color": "#ffffff", "weight": 2})); + let r = run(&mut s, "stroke.set", json!({"ids": [g.0], "join": "round"})); + assert_eq!(r["objects"], 1, "{r}"); + let gs = node(&s, g).appearance.stroke().unwrap().clone(); + assert_eq!((gs.join, gs.width, gs.paint.color().map(|c| c.to_hex())), (LineJoin::Round, 2.0, Some("#ffffff".to_string()))); + for id in [a, b] { + assert!(node(&s, id).appearance.stroke().is_none(), "a member keeps no stroke"); + } + // A group without a stroke of its own still reaches its members; for a join alone the row + // made on each has no paint, so nothing becomes visible. + let (c, d) = (rect(&mut s, 400.0), rect(&mut s, 550.0)); + for id in [c, d] { + run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1})); + } + let h = NodeId(run(&mut s, "object.group", json!({"ids": [c.0, d.0]}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"ids": [h.0], "join": "bevel"})); + for id in [c, d] { + let st = node(&s, id).appearance.stroke().cloned().expect("a stroke row for the join"); + assert_eq!((st.paint, st.join), (Paint::None, LineJoin::Bevel), "no paint for a join alone"); + } + // A weight is a visible stroke: an object without one gets the black stroke, as before. + let e = rect(&mut s, 700.0); + run(&mut s, "appearance.removeItem", json!({"ids": [e.0], "index": 1})); + run(&mut s, "stroke.set", json!({"ids": [e.0], "weight": 3})); + let st = node(&s, e).appearance.stroke().cloned().unwrap(); + assert_eq!((st.paint, st.width), (Paint::solid(Color::BLACK), 3.0)); +} + #[test] fn text_items_take_item_paint() { let mut s = session(); From e846710e85979ef140ca8f79077fbf295ace9e7d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:02:26 -0800 Subject: [PATCH 097/126] MCP: the css.exportFile catalog entry matches its desktop description --- crates/mcp/src/ui_catalog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c6cf12857f..78b23a3020 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1486,7 +1486,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}", ), ( "file.print", From 49c8a380a1e2ed2f44b39d23fc1068cfea09cf5a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:02:45 -0800 Subject: [PATCH 098/126] Scripts: flattening a rope keeps it rooted while its copy is reserved flat_charged and the property-key path now root the rope itself, so a string held only by native code (a getter's result for ++o.x, a valueOf result, a toString key) still counts what it holds when the reservation collects. The per-call roots for these cases are removed. --- crates/js/src/builtins/json.rs | 3 +-- crates/js/src/interp.rs | 17 +++++++---------- crates/js/src/string_builtins.rs | 5 +++-- crates/js/src/text_tests.rs | 1 + 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index d32344946e..364ec99696 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -474,8 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Se let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; s.gap = vec![u16::from(b' '); n]; } - // Rooted, so what the string holds is still counted while its copy is reserved. - Value::String(t) => s.gap = interp.rooted(&[Value::String(t.clone())], |i| flat_charged(i, &t))?.iter().take(10).copied().collect(), + Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), _ => {} } Ok(s) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 696df47784..ff821b2171 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2626,8 +2626,7 @@ impl Interp { Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; - // Rooted, so what a string holds is still counted while its copy is reserved. - return self.rooted(std::slice::from_ref(&p), |i| i.to_number(&p)); + return self.to_number(&p); } }) } @@ -2662,19 +2661,19 @@ impl Interp { Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), _ => { let s = self.to_str(v)?; - // Rooted, so what the string holds is still counted while its copy is reserved. - self.rooted(&[Value::String(s.clone())], |i| i.flat_reserved(&s)) + self.flat_reserved(&s) } } } /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that - /// copy's bytes are reserved and added to the heap first, without taking fuel. Everything - /// the caller still needs must be reachable from the roots. + /// copy's bytes are reserved and added to the heap first, without taking fuel. The rope stays + /// rooted while the copy is reserved; everything else the caller still needs must be + /// reachable from the roots. fn flat_reserved(&mut self, s: &Str) -> JsResult<Key> { if s.is_rope() { let bytes = string_bytes(s.len()); - self.reserve_heap(bytes)?; + self.rooted(&[Value::String(s.clone())], |i| i.reserve_heap(bytes))?; self.heap.bytes = self.heap.bytes.saturating_add(bytes); } Ok(s.flat()) @@ -2832,9 +2831,7 @@ impl Interp { | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { - // Rooted, so what the string holds is still counted while its copy is reserved. - let units = self.rooted(&[x.clone(), y.clone()], |i| flat_charged(i, s))?; - return Ok(*n == string_to_number(&units)); + return Ok(*n == string_to_number(&flat_charged(self, s)?)); } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf54..e1baed6c23 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -109,10 +109,11 @@ fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, it } /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's -/// bytes are reserved and added to the heap, and its units charged as fuel, first. +/// bytes are reserved and added to the heap, and its units charged as fuel, first. The rope stays +/// rooted while the copy is reserved, so what it holds is still counted. pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult<Rc<[u16]>> { if s.is_rope() { - reserve_units(interp, s.len())?; + interp.rooted(&[Value::String(s.clone())], |interp| reserve_units(interp, s.len()))?; } Ok(s.flat()) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index d2627cfb58..01fc613cc3 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -357,6 +357,7 @@ fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { "+({ valueOf: mk })", "({})[{ toString: mk }]", "({ valueOf: mk }) == 1", + "++({ get x() { return mk(); } }).x", "JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())", ] .into_iter() From 44d5d20f5811ac34d5d2e88dcdd4eac94442bdeb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:02:47 -0800 Subject: [PATCH 099/126] Paragraph Styles: Style Options edits a style's tab stops Paragraph Style Options and New Paragraph Style gain a Tabs section. It lists the style's stops and edits the selected stop's Alignment, position (X:, or Y: for vertical type), Leader and Align On character. Add puts a left stop 36 pt past the furthest one, Delete Tab removes the selected stop, and Clear All Tabs leaves the style with no stops. OK sorts the stops by position and keeps the stop edited last when two share one position. Reset Panel removes the style's tabs. paraStyle.new, paraStyle.setAttrs and paraStyle.edit check changed tabs stop by stop: at most 256 stops, and each changed stop's position from 0 to 100000 pt and leader of at most 8 characters. Stops the style already has are kept as they are. Changed tabs are sorted by position, keeping the first of stops at one position. text.tabs.set rejects a leader longer than 8 characters, an Align On value of more than one character and more than 256 stops. Loading paragraph styles skips a style whose indents, spacing, hyphenation, justification or tabs are out of range. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/text/tabs_lists.rs | 9 + crates/engine/src/cmd/tabs.rs | 44 ++- crates/engine/src/cmd/textstyles.rs | 72 +++- crates/engine/src/cmd/textstyles/tests.rs | 74 +++++ crates/ui-egui/src/dialogs/new_text_style.rs | 1 + .../ui-egui/src/dialogs/para_style_options.rs | 307 +++++++++++++++++- .../src/dialogs/para_style_options/tests.rs | 245 +++++++++++++- crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/tabs.rs | 2 +- docs/mcp.md | 23 +- docs/target-app-parity.md | 3 +- 32 files changed, 845 insertions(+), 43 deletions(-) diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index c4ec92b70b..5ed95b78e1 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -28,6 +28,15 @@ pub struct TabStop { pub align_on: char, } +impl TabStop { + /// The largest stop position `text.tabs.set` and a paragraph style take, in points. + pub const MAX_POSITION: f64 = 100_000.0; + /// The most leader characters a stop repeats. + pub const MAX_LEADER: usize = 8; + /// The most stops `text.tabs.set` and a paragraph style take in one list. + pub const MAX_STOPS: usize = 256; +} + fn default_align_on() -> char { '.' } diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 331227e3be..04a2c5504a 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); supplied align, leader and alignOn must be strings, and alignOn cannot be empty", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -38,10 +38,14 @@ pub fn specs() -> Vec<CommandSpec> { fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { const C: &str = "text.tabs.set"; let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; + let max = TabStop::MAX_STOPS; + if arr.len() > max { + return Err(bad(C, format!("at most {max} tab stops"))); + } let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; - if !(0.0..=100_000.0).contains(&position) { + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { return Err(bad(C, "positions must be between 0 and 100000 pt")); } let align = match s.get("align") { @@ -60,16 +64,25 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { None | Some(Value::Null) => "", Some(Value::String(value)) => value.as_str(), Some(_) => return Err(bad(C, "`leader` must be a string")), + }; + let (label, max) = ("`leader`", TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(bad(C, format!("{label} must be at most {max} characters"))); } - .chars() - .take(8) - .collect(); let align_on = match s.get("alignOn") { None | Some(Value::Null) => '.', - Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?, + Some(Value::String(value)) => { + let mut chars = value.chars(); + let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?; + if chars.next().is_some() { + let label = "`alignOn`"; + return Err(bad(C, format!("{label} must be one character"))); + } + c + } Some(_) => return Err(bad(C, "`alignOn` must be a string")), }; - stops.push(TabStop { position, align, leader, align_on }); + stops.push(TabStop { position, align, leader: leader.to_string(), align_on }); } stops.sort_by(|a, b| a.position.total_cmp(&b.position)); stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); @@ -128,7 +141,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> { #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -139,6 +152,21 @@ mod tests { } } + /// A leader of more than eight characters, an `alignOn` of more than one character and a list + /// of more than 256 stops are errors, not cut short. + #[test] + fn long_leaders_long_align_on_and_too_many_stops_are_errors() { + let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); + assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); + assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect(); + assert!(err(Value::from(many.clone())).contains("at most 256 tab stops")); + let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap(); + assert_eq!(ok.len(), 256); + let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); + assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index d30e69df68..dc424e1157 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef}; use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; pub fn specs() -> Vec<CommandSpec> { let mut v = style_cmds!( @@ -148,13 +148,13 @@ pub fn specs() -> Vec<CommandSpec> { "paraStyle", "Paragraph Styles", "Paragraph", - "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt" + "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position" )); v.push(spec( "paraStyle.edit", "Edit Paragraph Style", &[], - "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt", + "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position", |s, p| edit_style(s, p, Kind::Para), true, )); @@ -569,8 +569,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma char_attrs(&st) } Kind::Para => { - let st = with_attrs(&ParaStyle::default(), m, &c)?; + let mut st = with_attrs(&ParaStyle::default(), m, &c)?; para_ranges(&st, m, kept, &c)?; + sort_tabs(&mut st, m, kept); para_attrs(&st) } }; @@ -587,7 +588,8 @@ const PARA_DISTANCE: f64 = 1296.0; /// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the /// ranges `text.setFormat` keeps them to: the indents and the space before and after, each /// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as -/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked. +/// `attrs` gives them), and the number of tab stops with the position and leader of each stop +/// `kept` does not hold. A value `kept` has is not checked. fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>, c: &str) -> Result<()> { let within = |key: &str, value: f64, lo: f64, hi: f64| { if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) } @@ -644,18 +646,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<Str } } if changed("tabs") { - let kept_tabs: Vec<f64> = kept - .and_then(|k| k.get("tabs")) - .and_then(Value::as_array) - .map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect()) - .unwrap_or_default(); - for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) { - within("tabs.position", stop.position, 0.0, 100_000.0)?; + let kept_tabs = kept_tab_stops(kept); + let max = TabStop::MAX_STOPS; + if st.tabs.len() > max.max(kept_tabs.len()) { + return Err(bad(c, format!("at most {max} tab stops"))); + } + for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) { + within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?; + let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER); + if stop.leader.chars().count() > max { + return Err(bad(c, format!("{label} must be at most {max} characters"))); + } } } Ok(()) } +/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out). +fn kept_tab_stops(kept: Option<&Map<String, Value>>) -> Vec<TabStop> { + let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array); + stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect() +} + +/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other, +/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`. +fn sort_tabs(st: &mut ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>) { + if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") { + st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + } +} + /// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a /// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes. fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> { @@ -1011,10 +1032,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name -/// is empty, which are skipped. → (the styles, the names in `src` of those skipped) +/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose +/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range +/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in +/// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) { - const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; + const RANGED: [&str; 16] = [ + "size", + "h_scale", + "v_scale", + "leading", + "baseline_shift", + "tcyUpDown", + "tcyLeftRight", + "stroke_width", + "left_indent", + "right_indent", + "first_line_indent", + "space_before", + "space_after", + "hyphenation", + "justification", + "tabs", + ]; let (mut defs, mut skipped) = (vec![], vec![]); for def in kind.defs(src) { let name = def.name.as_str(); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 87bca29048..e38798401e 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1729,3 +1729,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() { assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}"); } } + +/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept +/// as it is, and a stop at a held position with another alignment is a changed stop. A changed +/// stop's leader has at most eight characters and its Align On one character, a changed list has +/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position, +/// keeping the first of stops at one position. +#[test] +fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() { + let (mut s, _) = session_with_text("Text"); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap(); + let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held: Vec<Value> = + (0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect(); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone())); + let before = s.doc().unwrap().doc.clone(); + let mut moved = held.clone(); + moved[0]["align"] = json!("right"); + for (stops, why) in [ + (Value::from(moved), "`tabs.position`"), + (json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"), + (Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()), "at most 256 tab stops"), + (json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"), + ] { + for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] { + let params = match cmd { + "paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}), + _ => json!({"name": "Body", "attrs": {"tabs": stops}}), + }; + let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(error.contains(why), "{cmd}: {error}"); + } + } + let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()); + let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string(); + assert!(error.contains("at most 256 tab stops"), "{error}"); + assert_eq!(s.doc().unwrap().doc, before); + // The held stops stay while one is dropped and another attribute changes. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap(); + assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299)); + let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + assert_eq!( + def(&s)["tabs"], + json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."}, + {"position": 72.0, "align": "right", "alignOn": "."}]) + ); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap(); + assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set"); +} + +/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing, +/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes, +/// and load the others with their stops. +#[test] +fn loading_skips_paragraph_styles_out_of_range() { + let file = [ + ("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})), + ("Indent", json!({"left_indent": 2000.0})), + ("Space", json!({"space_after": -1300.0})), + ("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})), + ("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})), + ("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})), + ]; + let data = styles_file(&[], &file); + for cmd in ["paraStyle.load", "textStyle.loadAll"] { + let (mut s, _) = session_with_text("Title"); + let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r }; + assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}"); + let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap(); + assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}"); + } +} diff --git a/crates/ui-egui/src/dialogs/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs index 91731006f1..4978a6a1f6 100644 --- a/crates/ui-egui/src/dialogs/new_text_style.rs +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -40,6 +40,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result<Value fields.insert("section".into(), json!("general")); let attrs = target.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default(); fields.extend(super::para_style_options::form_fields(&attrs, app.session.general_unit())); + fields.insert("__vertical".into(), json!(super::para_style_options::vertical_text(app))); } else { fields.insert("section".into(), json!("general")); fields.insert("__smallCapsSize".into(), json!(d.setup.small_caps_size)); diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index ffd0a23682..34a623cc5c 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -1,18 +1,20 @@ //! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic -//! Paragraph Formats (alignment, indents and spacing), Composition (composer and Roman Hanging -//! Punctuation), Hyphenation and Justification. +//! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition +//! (composer and Roman Hanging Punctuation), Hyphenation and Justification. //! //! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves //! its attribute out of the style. Distances are shown and typed in the General unit and kept in //! points. The Hyphenation and Justification options are one attribute each (`hyphenation`, //! `justification`): when any of their fields is set, a field left empty takes its default value. -//! A field still as it opened keeps the style's stored value as it is. +//! The `tabs` field is unset (null) or a list of stops, an empty list included, and is the `tabs` +//! attribute. A field still as it opened keeps the style's stored value as it is. //! //! Behavior reference (public documentation): //! <https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html>. use serde_json::{Map, Value, json}; -use vectorcraft_doc::{HyphenationOptions, JustificationOptions, Unit}; +use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL; +use vectorcraft_doc::{HyphenationOptions, JustificationOptions, TabAlign, TabStop, Unit}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -21,7 +23,11 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 5] = ["general", "basic", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "tabs", "composition", "hyphenation", "justification"]; +/// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's +/// order. +const TAB_ALIGNS: [(TabAlign, &str); 4] = + [(TabAlign::Left, "left"), (TabAlign::Center, "center"), (TabAlign::Right, "right"), (TabAlign::Decimal, "decimal")]; /// The distance fields of Basic Paragraph Formats, which are also the attributes they set. const DISTANCES: [&str; 5] = ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"]; /// The range of the indents, the space before and after, and the Hyphenation Zone (pt), as @@ -98,6 +104,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "tabs", "section", ] }, @@ -112,6 +119,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> { let doc = &app.session.active().ok_or("no document open")?.doc; let def = doc.para_styles.iter().find(|s| s.name == name).ok_or_else(|| format!("no style `{name}`"))?; let mut fields = form_fields(&def.attrs, unit); + fields.insert("__vertical".into(), json!(vertical_text(app))); fields.insert("name".into(), json!(name)); fields.insert("__style".into(), json!(name)); fields.insert("section".into(), json!("general")); @@ -129,6 +137,76 @@ pub(crate) fn form_fields(attrs: &Map<String, Value>, unit: Unit) -> Map<String, fields } +/// Whether the first selected text is vertical type, where the Tabs section names a stop's +/// position Y and its Left and Right alignments Top and Bottom. +pub(crate) fn vertical_text(app: &VectorcraftApp) -> bool { + crate::panels::character::first_selected_text(app).is_some_and(|t| t.vertical) +} + +/// The `tabs` field for a style's `tabs` attribute: unset (null) when the style leaves it out, +/// otherwise one entry per stop ([`stop_field`]). +fn tabs_field(value: Option<&Value>, unit: Unit) -> Value { + let Some(stops) = value.and_then(Value::as_array) else { return Value::Null }; + Value::Array(stops.iter().filter_map(|s| serde_json::from_value::<TabStop>(s.clone()).ok()).map(|s| stop_field(&s, unit)).collect()) +} + +/// A tab stop's entry in the `tabs` field: its position as text in `unit`, its alignment, leader +/// and Align On character, and the position in points it shows (`__pt`), which a position still as +/// it shows keeps exactly. +fn stop_field(s: &TabStop, unit: Unit) -> Value { + let align = TAB_ALIGNS.iter().find(|(a, _)| *a == s.align).map_or("left", |(_, key)| *key); + json!({"position": unit.format(s.position), "align": align, "leader": s.leader, "alignOn": s.align_on.to_string(), "__pt": s.position}) +} + +/// The position in points of a `tabs` entry: its `__pt` while its text is as that shows in `unit`, +/// else the text read in `unit`; `None` when the text is not a distance. +fn stop_position(row: &Value, unit: Unit) -> Option<f64> { + let text = row.get("position").and_then(Value::as_str).map_or("", str::trim); + row.get("__pt").and_then(Value::as_f64).filter(|pt| pt.is_finite() && unit.format(*pt) == text).or_else(|| unit.parse(text)) +} + +/// The tab stop a `tabs` entry sets, or why it sets none: its position must be a distance from 0 to +/// 100000 pt (`label` names the field), its alignment one of [`TAB_ALIGNS`], its leader text of at +/// most eight characters and its Align On character one character. A leader or Align On character +/// left out is none or a period. +fn read_stop(row: &Value, unit: Unit, label: &str) -> Result<TabStop, String> { + let position = stop_position(row, unit).ok_or_else(|| format!("{label} must be a number"))?; + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { + let (min, max) = (unit.format(0.0), unit.format(TabStop::MAX_POSITION)); + return Err(format!("{label} must be a number from {min} to {max}")); + } + let other = match row.get("align") { + None | Some(Value::Null) => "left", + Some(v) => v.as_str().unwrap_or(""), + }; + let align = TAB_ALIGNS.iter().find(|(_, key)| *key == other).map(|(a, _)| *a).ok_or_else(|| format!("unknown align `{other}`"))?; + let leader = match row.get("leader") { + None | Some(Value::Null) => "", + Some(Value::String(s)) => s.as_str(), + Some(_) => { + let key = "leader"; + return Err(format!("`{key}` must be a string")); + } + }; + let (label, max) = (caption(tl!("Leader:")), TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(format!("{label} must be at most {max} characters")); + } + let label = caption(tl!("Align On:")); + let align_on = match row.get("alignOn") { + None | Some(Value::Null) => '.', + Some(Value::String(s)) => { + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => c, + _ => return Err(format!("{label} must be one character")), + } + } + Some(_) => return Err(format!("{label} must be one character")), + }; + Ok(TabStop { position, align, leader: leader.into(), align_on }) +} + /// A distance in points as its field shows it in `unit`. fn distance_field(value: Option<&Value>, unit: Unit) -> Value { value.and_then(Value::as_f64).map_or_else(|| json!(""), |pt| json!(unit.format(pt))) @@ -174,6 +252,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>, unit: Unit) -> Map<S } fields.insert("auto_leading".into(), number_field(justification.and_then(|j| j.get("auto_leading")))); fields.insert("single_word".into(), justification.and_then(|j| j.get("single_word")).filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); + fields.insert("tabs".into(), tabs_field(attrs.get("tabs"), unit)); fields } @@ -292,9 +371,60 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> { let same = JUSTIFICATION_FIELDS.iter().all(|key| unchanged(key, "__resetJustification")); let value = if same { None } else { justification(d, &original, &original_fields)? }; keep_or(&mut attrs, "justification", same, value); + + let same = unchanged("tabs", "__resetTabs"); + let value = if same { None } else { tab_stops(d, &original, unit)? }; + keep_or(&mut attrs, "tabs", same, value); Ok(attrs) } +/// The `tabs` attribute the Tabs section sets: none while the list is unset; otherwise its stops +/// sorted by position. Of stops within 1e-6 pt of each other, the one edited last (`__tabEdited`) +/// is kept, else the first in the list. A stop as the style has it is kept without a check, and any +/// other is read by [`read_stop`]. An error when the list has more than 256 stops and more than the +/// style has. +fn tab_stops(d: &Dialog, original: &Map<String, Value>, unit: Unit) -> Result<Option<Value>, String> { + let rows = match d.fields.get("tabs") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(rows)) => rows, + Some(_) => { + let key = "tabs"; + return Err(format!("`{key}` must be an array")); + } + }; + let kept: Vec<TabStop> = + original.get("tabs").and_then(Value::as_array).into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect(); + let max = TabStop::MAX_STOPS; + if rows.len() > max.max(kept.len()) { + return Err(format!("at most {max} tab stops")); + } + let label = caption(if d.bool("__vertical") { tl!("Y:") } else { tl!("X:") }); + let kept_rows: Vec<Value> = kept.iter().map(|s| stop_field(s, unit)).collect(); + let mut stops = Vec::with_capacity(rows.len()); + for row in rows { + match kept_rows.iter().position(|k| k == row) { + Some(i) => stops.push(kept[i].clone()), + None => stops.push(read_stop(row, unit, label)?), + } + } + let edited = d.fields.get("__tabEdited").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let mut order: Vec<usize> = (0..stops.len()).collect(); + order.sort_by(|&a, &b| stops[a].position.total_cmp(&stops[b].position).then(a.cmp(&b))); + let mut keep: Vec<usize> = Vec::with_capacity(order.len()); + for i in order { + match keep.last_mut() { + Some(last) if (stops[*last].position - stops[i].position).abs() < 1e-6 => { + if Some(i) == edited { + *last = i; + } + } + _ => keep.push(i), + } + } + let stops: Vec<TabStop> = keep.into_iter().map(|i| stops[i].clone()).collect(); + Ok(Some(serde_json::to_value(stops).map_err(|e| e.to_string())?)) +} + /// The `hyphenation` attribute the Hyphenation fields set: none when every field is empty or /// unset; otherwise the style's options (or the defaults) with each changed field written over /// them, an empty one at its default. @@ -416,15 +546,24 @@ fn reset_justification(d: &mut Dialog) { } } +/// Reset Panel in Tabs: the tab stops become unset, which leaves `tabs` out of the style. +fn reset_tabs(d: &mut Dialog) { + d.fields.insert("__resetTabs".into(), json!(true)); + d.fields.insert("tabs".into(), Value::Null); + d.fields.remove("__tabEdited"); + d.fields.insert("__tabSel".into(), json!(0)); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "tabs", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. pub(crate) fn reset_section(d: &mut Dialog) { match d.str("section").as_str() { "basic" => reset_basic(d), + "tabs" => reset_tabs(d), "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), @@ -449,9 +588,8 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result<Value, String> { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - for (id, label) in - SECTIONS.into_iter().zip([tl!("General"), tl!("Basic Paragraph Formats"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]) - { + let labels = [tl!("General"), tl!("Basic Paragraph Formats"), tl!("Tabs"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]; + for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); } @@ -529,6 +667,156 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The editing fields of the selected tab stop, with the `tabs` entry key each one shows. +const TAB_EDITORS: [(&str, &str); 3] = [("__tabPosition", "position"), ("__tabLeader", "leader"), ("__tabAlignOn", "alignOn")]; + +/// Tabs: the selected stop's Alignment, X (Y in vertical type), Leader and Align On, which only a +/// decimal stop uses; Add ([`add_tab`]), Delete Tab ([`delete_tab`]) and Clear All Tabs +/// ([`clear_tabs`]); and the list of stops, one row each, a click on which selects its stop, which +/// scrolls when it is taller than 160 points. A change to a field makes its stop the one edited last. +pub(crate) fn tabs_body(ui: &mut egui::Ui, d: &mut Dialog) { + let vertical = d.bool("__vertical"); + let (mut rows, mut sel, _) = tab_rows(d); + let mut changed = false; + let has = sel < rows.len(); + let shown = TAB_EDITORS.map(|(_, key)| rows.get(sel).and_then(|r| r.get(key)).and_then(Value::as_str).unwrap_or("").to_string()); + for ((field, _), text) in TAB_EDITORS.iter().zip(&shown) { + d.fields.insert((*field).into(), json!(text)); + } + let align = rows.get(sel).and_then(|r| r.get("align")).and_then(Value::as_str).unwrap_or("left").to_string(); + egui::Grid::new("paragraph-style-tabs").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + let row = ui.label(tl!("Alignment:")); + let names: Vec<&str> = TAB_ALIGNS.iter().map(|(a, _)| crate::panels::tabs::align_name(*a, vertical)).collect(); + let shown = TAB_ALIGNS.iter().position(|(_, key)| *key == align).and_then(|i| names.get(i).copied()).unwrap_or(""); + ui.add_enabled_ui(has, |ui| { + ui.label_inputs_by(row.id, |ui| { + if let Some(i) = crate::widgets::dropdown(ui, (KIND, "tab_align"), shown, &names, 190.0) + && let (Some(r), Some((_, key))) = (rows.get_mut(sel).and_then(Value::as_object_mut), TAB_ALIGNS.get(i)) + { + r.insert("align".into(), json!(key)); + changed = true; + } + }); + }); + ui.end_row(); + field_row(ui, d, "__tabPosition", if vertical { tl!("Y:") } else { tl!("X:") }, "", has); + field_row(ui, d, "__tabLeader", tl!("Leader:"), "", has); + field_row(ui, d, "__tabAlignOn", tl!("Align On:"), "", has && align == "decimal"); + }); + for ((field, key), was) in TAB_EDITORS.iter().zip(&shown) { + let typed = d.str(field); + if let Some(r) = rows.get_mut(sel).and_then(Value::as_object_mut) + && typed != *was + { + r.insert((*key).into(), json!(typed)); + changed = true; + } + } + if changed { + d.fields.insert("tabs".into(), Value::Array(rows.clone())); + d.fields.insert("__tabEdited".into(), json!(sel)); + } + ui.add_space(6.0); + let (mut add, mut delete, mut clear) = (false, false, false); + let can_add = new_tab_position(&rows, unit_of(d)).is_some(); + ui.horizontal(|ui| { + add = ui.add_enabled(can_add, egui::Button::new(tl!("Add"))).clicked(); + delete = ui.add_enabled(has, egui::Button::new(tl!("Delete Tab"))).clicked(); + clear = ui.add_enabled(!rows.is_empty(), egui::Button::new(tl!("Clear All Tabs"))).clicked(); + }); + ui.add_space(4.0); + let reveal = d.fields.remove("__tabScroll").is_some(); + egui::ScrollArea::vertical().id_salt("paragraph-style-tab-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let a = r.get("align").and_then(Value::as_str).and_then(|key| TAB_ALIGNS.iter().find(|(_, k)| *k == key)).map(|(a, _)| *a); + let name = a.map_or("", |a| crate::panels::tabs::align_name(a, vertical)); + let mut text = format!("{} {name}", r.get("position").and_then(Value::as_str).unwrap_or("")); + if let Some(leader) = r.get("leader").and_then(Value::as_str).filter(|l| !l.is_empty()) { + text += &format!(" {leader}"); + } + let row = ui.selectable_label(i == sel, text); + if reveal && i == sel { + ui.scroll_to_rect_animation(row.rect, None, egui::style::ScrollAnimation::none()); + } + if row.clicked() { + sel = i; + } + } + }); + d.fields.insert("__tabSel".into(), json!(sel)); + if add { + add_tab(d); + } else if delete { + delete_tab(d); + } else if clear { + clear_tabs(d); + } +} + +/// The `tabs` entries of `d` (none while unset), the selected one's index (`__tabSel`, at most the +/// last) and the index of the one edited last (`__tabEdited`). +fn tab_rows(d: &Dialog) -> (Vec<Value>, usize, Option<usize>) { + let rows: Vec<Value> = d.fields.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default(); + let index = |key: &str| d.fields.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let sel = index("__tabSel").unwrap_or(0).min(rows.len().saturating_sub(1)); + (rows, sel, index("__tabEdited")) +} + +/// The position (pt) Add gives a new stop among `rows`: half an inch past the furthest stop that +/// reads, half an inch when none does, and at most 100000 pt. `None` while the list holds 256 stops +/// or when a stop already sits at that position. +fn new_tab_position(rows: &[Value], unit: Unit) -> Option<f64> { + if rows.len() >= TabStop::MAX_STOPS { + return None; + } + let positions: Vec<f64> = rows.iter().filter_map(|r| stop_position(r, unit)).collect(); + let furthest = positions.iter().copied().filter(|pt| (0.0..=TabStop::MAX_POSITION).contains(pt)).reduce(f64::max); + let position = furthest.map_or(DEFAULT_TAB_INTERVAL, |pt| pt + DEFAULT_TAB_INTERVAL).min(TabStop::MAX_POSITION); + (!positions.iter().any(|pt| (pt - position).abs() < 1e-6)).then_some(position) +} + +/// Add in Tabs: a left stop at [`new_tab_position`], which becomes the selected and last edited +/// stop and is scrolled into view in the list. Nothing when that gives no position. +pub(crate) fn add_tab(d: &mut Dialog) { + let unit = unit_of(d); + let (mut rows, _, _) = tab_rows(d); + let Some(position) = new_tab_position(&rows, unit) else { return }; + rows.push(stop_field(&TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: '.' }, unit)); + let at = rows.len() - 1; + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabSel".into(), json!(at)); + d.fields.insert("__tabEdited".into(), json!(at)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Delete Tab in Tabs: remove the selected stop; the next one (or the last) becomes selected. +pub(crate) fn delete_tab(d: &mut Dialog) { + let (mut rows, sel, edited) = tab_rows(d); + if sel >= rows.len() { + return; + } + rows.remove(sel); + match edited { + Some(e) if e == sel => { + d.fields.remove("__tabEdited"); + } + Some(e) if e > sel => { + d.fields.insert("__tabEdited".into(), json!(e - 1)); + } + _ => {} + } + d.fields.insert("__tabSel".into(), json!(sel.min(rows.len().saturating_sub(1)))); + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Clear All Tabs in Tabs: an empty list of stops, which the style then sets. +pub(crate) fn clear_tabs(d: &mut Dialog) { + d.fields.insert("tabs".into(), json!([])); + d.fields.insert("__tabSel".into(), json!(0)); + d.fields.remove("__tabEdited"); +} + /// Composition: the Single-line or Every-line Composer, and Roman Hanging Punctuation. pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("paragraph-style-composition").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { @@ -624,6 +912,7 @@ pub(crate) fn general_body(ui: &mut egui::Ui, d: &mut Dialog) { pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { match d.str("section").as_str() { "basic" => basic_body(ui, d), + "tabs" => tabs_body(ui, d), "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 42fa0bd491..38d158b102 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -162,6 +162,7 @@ fn reset_panel_clears_only_its_section() { let stored = def(&app, "Body"); for (section, cleared) in [ ("basic", &["justify", "space_after"][..]), + ("tabs", &["tabs"]), ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), @@ -239,7 +240,15 @@ fn new_paragraph_style_offers_the_sections() { set(&mut app, "section", json!(section)); texts += &crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); } - for label in ["Basic Paragraph Formats", "Composition", "First-line Left Indent", "Hyphenate Capitalized Words", "Single Word Justification:"] { + for label in [ + "Basic Paragraph Formats", + "Tabs", + "Composition", + "First-line Left Indent", + "Align On:", + "Hyphenate Capitalized Words", + "Single Word Justification:", + ] { assert!(texts.contains(label), "{label} is drawn: {texts}"); } set(&mut app, "section", json!("basic")); @@ -257,6 +266,237 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// The rows of the `tabs` field of the open dialog. +fn tab_rows(app: &VectorcraftApp) -> Vec<Value> { + app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() +} + +/// Tabs shows a style's stops in the General unit with their alignment, leader and Align On +/// character; a stop left as it was keeps its position exactly, a changed one is read in the unit, +/// and the stops are stored sorted by position. Add puts a left stop half an inch past the furthest +/// stop, Delete Tab removes the selected stop and Clear All Tabs leaves the style an empty list. +#[test] +fn tabs_section_edits_the_style_stops() { + let mut app = app(); + let stops = json!([{"position": 36.0, "align": "right"}, {"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"left_indent": 3.0, "tabs": stops}})).unwrap(); + let stored = def(&app, "Body"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = tab_rows(&app); + let shown: Vec<(&str, &str, &str, &str)> = rows + .iter() + .map(|r| (r["position"].as_str().unwrap(), r["align"].as_str().unwrap(), r["leader"].as_str().unwrap(), r["alignOn"].as_str().unwrap())) + .collect(); + assert_eq!(shown, [("36 pt", "right", "", "."), ("72.123 pt", "decimal", ".", ",")]); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), stored, "nothing changed"); + + // The first stop moves past the second and turns centered; the second keeps its exact position. + let mut rows = rows; + rows[0]["position"] = json!("1.5 in"); + rows[0]["align"] = json!("center"); + set(&mut app, "tabs", Value::from(rows)); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!( + a["tabs"], + json!([{"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}, {"position": 108.0, "align": "center", "alignOn": "."}]) + ); + assert_eq!(a["left_indent"], json!(3.0), "other attributes stay"); + + let d = app.ui.dialog.as_mut().unwrap(); + add_tab(d); + assert_eq!((tab_rows(&app).len(), app.ui.dialog.as_ref().unwrap().fields["__tabSel"].clone()), (3, json!(2))); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"][2], json!({"position": 144.0, "align": "left", "alignOn": "."}), "half an inch past the furthest"); + set(&mut app, "__tabSel", json!(0)); + delete_tab(app.ui.dialog.as_mut().unwrap()); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"].as_array().unwrap().iter().map(|s| s["position"].clone()).collect::<Vec<_>>(), [json!(72.123456), json!(144.0)]); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"].as_array().map(Vec::len), Some(2)); + + // Clear All Tabs leaves an empty list set, which Reset Panel removes; a new stop on a style + // without stops goes at half an inch. + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + clear_tabs(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([])); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"], json!([]), "an empty list is stored"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + app.run("ui.paraStyleOptions.resetPanel", json!({})).unwrap(); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert!(!a.contains_key("tabs") && a["left_indent"] == json!(3.0), "{a:?}"); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([{"position": 36.0, "align": "left", "alignOn": "."}])); +} + +/// Of stops typed at one position, the one edited last is kept; with none edited, the first in the +/// list is. +#[test] +fn a_repeated_tab_position_keeps_the_stop_edited_last() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = json!([{"position": "36", "align": "right"}, {"position": "0.5 in", "align": "center"}, {"position": "10", "align": "left"}]); + set(&mut app, "tabs", rows); + let kept = |app: &VectorcraftApp| edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"].clone(); + assert_eq!(kept(&app), json!([{"position": 10.0, "align": "left", "alignOn": "."}, {"position": 36.0, "align": "right", "alignOn": "."}])); + set(&mut app, "__tabEdited", json!(1)); + assert_eq!(kept(&app)[1]["align"], json!("center")); +} + +/// The list of stops scrolls, so a style with 256 stops keeps the Tabs section short enough for the +/// dialog's buttons to stay on screen. +#[test] +fn a_long_tab_list_scrolls() { + let mut app = app(); + let stops: Vec<Value> = (0..256).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut height = f32::INFINITY; + let mut out = ctx.run_ui(Default::default(), |ui| { + let top = ui.cursor().top(); + tabs_body(ui, d); + height = ui.cursor().top() - top; + }); + out.textures_delta.clear(); + assert!(height < 400.0, "the Tabs section is {height} points tall"); +} + +/// The stop Add puts at the end of a long list is scrolled into view: its row in the list is drawn +/// inside the list's visible area. +#[test] +fn an_added_tab_stop_is_scrolled_into_view() { + let mut app = app(); + let stops: Vec<Value> = (0..100).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |d: &mut Dialog| { + let mut out = ctx.run_ui(Default::default(), |ui| tabs_body(ui, d)); + out.textures_delta.clear(); + out.shapes.iter().any(|c| match &c.shape { + egui::Shape::Text(t) => t.galley.text().starts_with("3636 pt ") && c.clip_rect.intersects(t.visual_bounding_rect()), + _ => false, + }) + }; + draw(d); + add_tab(d); + let shown: Vec<bool> = (0..3).map(|_| draw(d)).collect(); + assert_eq!(shown.last(), Some(&true), "{shown:?}"); +} + +/// Add does not put a stop where one already is: with a stop at 100000 pt, the largest position, +/// it adds nothing and the Add button is disabled, so the existing stop keeps its settings. +#[test] +fn add_leaves_a_stop_at_the_largest_position_alone() { + let mut app = app(); + let stops = json!([{"position": 100000.0, "align": "right", "leader": "-"}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let before = tab_rows(&app); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(tab_rows(&app), before, "no stop is added"); + assert!(new_tab_position(&before, Unit::Points).is_none(), "Add is disabled"); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), def(&app, "Body"), "the stop is kept"); +} + +/// A tab position out of range or not a distance, an unknown alignment, a leader of more than eight +/// characters, an Align On entry that is not one character, more than 256 stops, a `tabs` value that +/// is not a list and a stop that is not an object are errors that keep the dialog open and the style +/// as it was. Vertical type names the position Y. +#[test] +fn invalid_tab_stops_keep_the_dialog_open() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + let stop = |key: &str, value: Value| { + let mut stop = json!({"position": "36", "align": "decimal", "leader": "", "alignOn": "."}); + stop[key] = value; + json!([stop]) + }; + let many = Value::from((0..257).map(|i| json!({"position": format!("{i}"), "align": "left"})).collect::<Vec<_>>()); + for (tabs, why) in [ + (stop("position", json!("-1")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("100001 pt")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("far")), "X must be a number"), + (stop("align", json!("diagonal")), "unknown align `diagonal`"), + (stop("leader", json!("123456789")), "Leader must be at most 8 characters"), + (stop("alignOn", json!("")), "Align On must be one character"), + (stop("alignOn", json!(",.")), "Align On must be one character"), + (many, "at most 256 tab stops"), + (json!("36"), "`tabs` must be an array"), + (json!({"position": "36"}), "`tabs` must be an array"), + (json!([5]), "X must be a number"), + ] { + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", tabs.clone()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), why, "{tabs}"); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + super::super::cancel(&mut app); + assert_eq!(def(&app, "Body"), Map::new()); + } + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "__vertical", json!(true)); + set(&mut app, "tabs", stop("position", json!("far"))); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), "Y must be a number"); + // Stops a style read from a file holds out of range stay while another stop is added. + super::super::cancel(&mut app); + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let held = json!([{"position": 200000.0, "align": "left", "leader": "123456789", "alignOn": "."}]); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), held.clone()); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + add_tab(app.ui.dialog.as_mut().unwrap()); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"][1], held[0]); +} + +/// Preview shows a style's new stops on the paragraphs that use it, and Cancel puts the document +/// and its history back. OK sets them in one undo step, a paragraph whose stops the Tabs panel set +/// keeps its own, and Undo and Redo restore both. Saving and opening the document keeps the stops. +#[test] +fn tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": [{"position": 36.0}]}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + app.run("text.tabs.set", json!({"ids": [id], "start": 4, "end": 4, "stops": [{"position": 50.0}]})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let new_stops = json!([{"position": "20", "align": "right", "leader": "."}, {"position": "80", "align": "decimal", "alignOn": ","}]); + let positions = |app: &VectorcraftApp| paras(app, id).iter().map(|p| p.tabs.iter().map(|t| t.position).collect::<Vec<_>>()).collect::<Vec<_>>(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + set(&mut app, "tabs", new_stops.clone()); + frame(&mut app); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]], "Preview shows the change"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", new_stops); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]]); + let p = paras(&app, id); + assert_eq!((p[0].tabs[0].leader.as_str(), p[0].tabs[1].align_on), (".", ',')); + let changed = app.session.doc().unwrap().doc.clone(); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&changed, false)).unwrap(); + let def_of = |d: &vectorcraft_doc::Document| d.para_styles.iter().find(|s| s.name == "Body").map(|s| s.attrs.clone()); + assert_eq!(def_of(&saved), def_of(&changed)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, changed); +} + /// The section list, Reset Panel and both buttons are drawn in every section, and the Hyphenation /// options are drawn whether or not Hyphenation is on. #[test] @@ -269,6 +509,7 @@ fn every_section_draws_its_fields() { for (section, label) in [ ("general", "Style Name:"), ("basic", "Space After Paragraph"), + ("tabs", "Clear All Tabs"), ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), @@ -282,7 +523,7 @@ fn every_section_draws_its_fields() { out.textures_delta.clear(); texts = crate::tests_labels::shapes_text(&out); } - for shown in [label, "General", "Basic Paragraph Formats", "Justification", "Reset Panel", "OK", "Cancel"] { + for shown in [label, "General", "Basic Paragraph Formats", "Tabs", "Justification", "Reset Panel", "OK", "Cancel"] { assert!(texts.contains(shown), "{shown} is drawn in {section}: {texts}"); } } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..5319afbb40 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5075,6 +5075,7 @@ digits Malayalam Malaiàlam @msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5 @msg `select` must be true or false `select` ha de ser true o false @msg `stops` must be an array `stops` ha de ser una matriu +@msg `{key}` must be an array `{key}` ha de ser una matriu @msg `svg` must be an object of SVG options (see document.formats) `svg` ha de ser un objecte d’opcions SVG (vegeu document.formats) @msg `to` must be "corner" or "smooth" `to` ha de ser "corner" o "smooth" @msg `{_1}` has no brushes to apply `{_1}` no té pinzells per aplicar @@ -6468,6 +6469,10 @@ anchor Points: Punts: @msg features lists at most {max} tags features admet com a màxim {max} etiquetes @msg unknown OpenType feature `{t}` funció OpenType desconeguda `{t}` @msg {label} must be a number above 0 and at most {max} {label} ha de ser un nombre superior a 0 i com a màxim {max} +@msg {label} must be at most {max} characters {label} ha de tenir com a màxim {max} caràcters +@msg {label} must be one character {label} ha de ser un sol caràcter +@msg at most {max} tab stops com a màxim {max} tabulacions +@msg unknown align `{other}` alineació desconeguda `{other}` @msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..126193bd44 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4554,6 +4554,7 @@ imageTrace Outline Obrys @msg `globalAdjust` must be an object `globalAdjust` musí být objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5 @msg `stops` must be an array `stops` musí být pole +@msg `{key}` must be an array `{key}` musí být pole @msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats) @msg `{_1}` is no save command `{_1}` není příkaz pro uložení @msg `{area}` is no recovery area name `{area}` není název oblasti obnovení @@ -6496,6 +6497,10 @@ anchor Points: Body: @msg features lists at most {max} tags features obsahuje nejvýše {max} značek @msg unknown OpenType feature `{t}` neznámá funkce OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musí být číslo větší než 0 a nejvýše {max} +@msg {label} must be at most {max} characters {label} smí mít nejvýše {max} znaků +@msg {label} must be one character {label} musí být jeden znak +@msg at most {max} tab stops nejvýše {max} zarážek tabulátoru +@msg unknown align `{other}` neznámé zarovnání `{other}` @msg no character style `{name}` styl znaků `{name}` neexistuje # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..4bf1071ca7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4169,6 +4169,7 @@ theme Appearance Erscheinungsbild @msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein @msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein @msg `stops` must be an array `stops` muss ein Array sein +@msg `{key}` must be an array `{key}` muss ein Array sein @msg `svg` must be an object of SVG options (see document.formats) `svg` muss ein Objekt mit SVG-Optionen sein (siehe document.formats) @msg `{_1}` is no save command `{_1}` ist kein Speicherbefehl @msg `{area}` is no recovery area name `{area}` ist kein Name eines Wiederherstellungsbereichs @@ -6090,6 +6091,10 @@ anchor Points: Punkte: @msg features lists at most {max} tags features enthält höchstens {max} Tags @msg unknown OpenType feature `{t}` unbekannte OpenType-Funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} muss eine Zahl über 0 und höchstens {max} sein +@msg {label} must be at most {max} characters {label} darf höchstens {max} Zeichen lang sein +@msg {label} must be one character {label} muss genau ein Zeichen sein +@msg at most {max} tab stops höchstens {max} Tabstopps +@msg unknown align `{other}` unbekannte Ausrichtung `{other}` @msg no character style `{name}` Zeichenformat `{name}` nicht gefunden # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..226eb76db9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2374,6 +2374,7 @@ axis Both Και τα δύο @msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο @msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5 @msg `stops` must be an array Το `stops` πρέπει να είναι πίνακας +@msg `{key}` must be an array Το `{key}` πρέπει να είναι πίνακας @msg `svg` must be an object of SVG options (see document.formats) Το `svg` πρέπει να είναι αντικείμενο επιλογών SVG (δείτε document.formats) @msg `{_1}` is no save command Το `{_1}` δεν είναι εντολή αποθήκευσης @msg `{area}` is no recovery area name Το `{area}` δεν είναι όνομα περιοχής ανάκτησης @@ -6447,6 +6448,10 @@ anchor Points: Σημεία: @msg features lists at most {max} tags Το features περιέχει έως {max} ετικέτες @msg unknown OpenType feature `{t}` άγνωστο χαρακτηριστικό OpenType `{t}` @msg {label} must be a number above 0 and at most {max} Το {label} πρέπει να είναι αριθμός μεγαλύτερος από 0 και έως {max} +@msg {label} must be at most {max} characters Το {label} πρέπει να έχει έως {max} χαρακτήρες +@msg {label} must be one character Το {label} πρέπει να είναι ένας χαρακτήρας +@msg at most {max} tab stops έως {max} στάσεις στηλοθέτη +@msg unknown align `{other}` άγνωστη στοίχιση `{other}` @msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..2ca9bcef39 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4245,6 +4245,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5 @msg `stops` must be an array `stops` debe ser una lista +@msg `{key}` must be an array `{key}` debe ser una lista @msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats) @msg `{_1}` is no save command `{_1}` no es un comando de guardado @msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación @@ -6439,6 +6440,10 @@ anchor Points: Puntos: @msg features lists at most {max} tags features admite como máximo {max} etiquetas @msg unknown OpenType feature `{t}` función OpenType desconocida `{t}` @msg {label} must be a number above 0 and at most {max} {label} debe ser un número mayor que 0 y como máximo {max} +@msg {label} must be at most {max} characters {label} debe tener como máximo {max} caracteres +@msg {label} must be one character {label} debe ser un solo carácter +@msg at most {max} tab stops como máximo {max} tabulaciones +@msg unknown align `{other}` alineación desconocida `{other}` @msg no character style `{name}` No existe el estilo de carácter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..64ea089a47 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4468,6 +4468,7 @@ imageTrace Outline Ääriviiva @msg `globalAdjust` must be an object `globalAdjust` on oltava objekti @msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5 @msg `stops` must be an array `stops` on oltava taulukko +@msg `{key}` must be an array `{key}` on oltava taulukko @msg `svg` must be an object of SVG options (see document.formats) `svg` on oltava SVG-asetusten objekti (ks. document.formats) @msg `{_1}` is no save command `{_1}` ei ole tallennuskomento @msg `{area}` is no recovery area name `{area}` ei ole palautusalueen nimi @@ -6526,6 +6527,10 @@ anchor Points: Pisteet: @msg features lists at most {max} tags features sisältää enintään {max} tunnistetta @msg unknown OpenType feature `{t}` tuntematon OpenType-ominaisuus `{t}` @msg {label} must be a number above 0 and at most {max} {label} on oltava luku, joka on yli 0 ja enintään {max} +@msg {label} must be at most {max} characters {label} saa olla enintään {max} merkkiä +@msg {label} must be one character {label} on oltava yksi merkki +@msg at most {max} tab stops enintään {max} sarkainkohtaa +@msg unknown align `{other}` tuntematon tasaus `{other}` @msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..dc00b8b6e5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4234,6 +4234,7 @@ axis Both Les deux @msg `globalAdjust` must be an object `globalAdjust` doit être un objet @msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5 @msg `stops` must be an array `stops` doit être un tableau +@msg `{key}` must be an array `{key}` doit être un tableau @msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats) @msg `{_1}` is no save command `{_1}` n’est pas une commande d’enregistrement @msg `{area}` is no recovery area name `{area}` n’est pas un nom de zone de récupération @@ -6491,6 +6492,10 @@ anchor Points: Points : @msg features lists at most {max} tags features contient au plus {max} balises @msg unknown OpenType feature `{t}` fonction OpenType inconnue `{t}` @msg {label} must be a number above 0 and at most {max} {label} doit être un nombre supérieur à 0 et au plus égal à {max} +@msg {label} must be at most {max} characters {label} doit compter au plus {max} caractères +@msg {label} must be one character {label} doit être un seul caractère +@msg at most {max} tab stops au plus {max} taquets de tabulation +@msg unknown align `{other}` alignement inconnu `{other}` @msg no character style `{name}` Style de caractère `{name}` introuvable # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..d5061a5cd5 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5532,6 +5532,9 @@ antiAlias Sharp शार्प @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए +@msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए +@msg {label} must be one character {label} एक वर्ण होना चाहिए +@msg at most {max} tab stops अधिकतम {max} टैब स्टॉप @msg {label} must be a number at most {max} {label} सबसे {max} पर एक नंबर होना चाहिए @msg {label} must be a number from -1296 to 1296 {label} को -1296 से 1296 के बीच की संख्या होना चाहिए @msg {label} must be a number from {min} to {max} {label} को {min} से {max} तक एक नंबर होना चाहिए @@ -5887,6 +5890,7 @@ antiAlias Sharp शार्प @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @msg `stop` must be a stop index `stop` एक स्टॉप इंडेक्स होना चाहिए @msg `stops` must be an array `stops` एक सारणी होना चाहिए +@msg `{key}` must be an array `{key}` एक सारणी होना चाहिए @msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें @msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें @msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..25b424b93b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4753,6 +4753,7 @@ imageTrace Outline Körvonal @msg `globalAdjust` must be an object `globalAdjust`: objektum kell @msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell @msg `stops` must be an array `stops`: tömb kell +@msg `{key}` must be an array `{key}`: tömb kell @msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats) @msg `to` must be "corner" or "smooth" `to`: "corner" vagy "smooth" kell @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`: "emBox", "capHeight", "xHeight" vagy "icfBox" kell @@ -6480,6 +6481,10 @@ anchor Points: Pontok: @msg features lists at most {max} tags a features legfeljebb {max} címkét tartalmazhat @msg unknown OpenType feature `{t}` ismeretlen OpenType-funkció: `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0-nál nagyobb és legfeljebb {max} értékű szám kell legyen +@msg {label} must be at most {max} characters {label} legfeljebb {max} karakter lehet +@msg {label} must be one character {label} pontosan egy karakter kell legyen +@msg at most {max} tab stops legfeljebb {max} tabulátor +@msg unknown align `{other}` ismeretlen igazítás: `{other}` @msg no character style `{name}` A(z) `{name}` karakterstílus nem található # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..bc98913d51 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4296,6 +4296,7 @@ axis Both Entrambi @msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5 @msg `stops` must be an array `stops` deve essere un array +@msg `{key}` must be an array `{key}` deve essere un array @msg `svg` must be an object of SVG options (see document.formats) `svg` deve essere un oggetto di opzioni SVG (vedi document.formats) @msg `{_1}` is no save command `{_1}` non è un comando di salvataggio @msg `{area}` is no recovery area name `{area}` non è il nome di un'area di recupero @@ -6124,6 +6125,10 @@ anchor Points: Punti: @msg features lists at most {max} tags features contiene al massimo {max} tag @msg unknown OpenType feature `{t}` funzione OpenType sconosciuta `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve essere un numero maggiore di 0 e al massimo {max} +@msg {label} must be at most {max} characters {label} deve avere al massimo {max} caratteri +@msg {label} must be one character {label} deve essere un solo carattere +@msg at most {max} tab stops al massimo {max} tabulazioni +@msg unknown align `{other}` allineamento sconosciuto `{other}` @msg no character style `{name}` Stile di carattere `{name}` non trovato # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..ffb1ee617a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4394,6 +4394,7 @@ swatch Bright Violet ビビッドバイオレット @msg `preserve` must be an object `preserve` にはオブジェクトを指定してください @msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください @msg `stops` must be an array `stops` には配列を指定してください +@msg `{key}` must be an array `{key}` には配列を指定してください @msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください @msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です @msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください @@ -6189,6 +6190,10 @@ anchor Points: ポイント: @msg features lists at most {max} tags features に指定できるタグは {max} 個までです @msg unknown OpenType feature `{t}` 不明な OpenType 機能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} には 0 より大きく {max} 以下の数値を指定してください +@msg {label} must be at most {max} characters {label} には {max} 文字以内で指定してください +@msg {label} must be one character {label} には 1 文字を指定してください +@msg at most {max} tab stops タブストップは {max} 個までです +@msg unknown align `{other}` 不明な揃え `{other}` @msg no character style `{name}` 文字スタイル `{name}` が見つかりません # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..24d38e9850 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4027,6 +4027,7 @@ font-technology Variable ცვლადი @msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი @msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5 @msg `stops` must be an array `stops` უნდა იყოს მასივი +@msg `{key}` must be an array `{key}` უნდა იყოს მასივი @msg `svg` must be an object of SVG options (see document.formats) `svg` უნდა იყოს SVG-ის პარამეტრების ობიექტი (იხ. document.formats) @msg `{_1}` is no save command `{_1}` შენახვის ბრძანება არ არის @msg `{area}` is no recovery area name `{area}` აღდგენის არის სახელი არ არის @@ -6522,6 +6523,10 @@ anchor Points: წერტილები: @msg features lists at most {max} tags features შეიძლება შეიცავდეს არაუმეტეს {max} ტეგს @msg unknown OpenType feature `{t}` უცნობი OpenType შესაძლებლობა `{t}` @msg {label} must be a number above 0 and at most {max} {label} უნდა იყოს 0-ზე მეტი და არაუმეტეს {max}-ისა რიცხვი +@msg {label} must be at most {max} characters {label} უნდა შეიცავდეს არაუმეტეს {max} სიმბოლოს +@msg {label} must be one character {label} უნდა იყოს ერთი სიმბოლო +@msg at most {max} tab stops არაუმეტეს {max} ტაბულაციის გაჩერება +@msg unknown align `{other}` უცნობი სწორება `{other}` @msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..f257966647 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4321,6 +4321,7 @@ theme Appearance 외관 @msg `preserve` must be an object `preserve`는 개체여야 합니다 @msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다 @msg `stops` must be an array `stops`는 배열이어야 합니다 +@msg `{key}` must be an array `{key}`은(는) 배열이어야 합니다 @msg `{k}` must be [x, y] `{k}`은(는) [x, y]여야 합니다 @msg a gradient needs at least two stops 그레이디언트에는 정지점이 두 개 이상 필요합니다 @msg give both `start` and `end` [x, y] (or neither) `start`와 `end` [x, y]를 모두 지정하세요(또는 둘 다 생략) @@ -6085,6 +6086,10 @@ anchor Points: 포인트: @msg features lists at most {max} tags features에는 태그를 최대 {max}개까지 지정할 수 있습니다 @msg unknown OpenType feature `{t}` 알 수 없는 OpenType 기능 `{t}` @msg {label} must be a number above 0 and at most {max} {label}은(는) 0 초과 {max} 이하의 숫자여야 합니다 +@msg {label} must be at most {max} characters {label}은(는) 최대 {max}자여야 합니다 +@msg {label} must be one character {label}은(는) 한 글자여야 합니다 +@msg at most {max} tab stops 탭 정지는 최대 {max}개까지 지정할 수 있습니다 +@msg unknown align `{other}` 알 수 없는 정렬 `{other}` @msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다 # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..c44333ad8b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4377,6 +4377,7 @@ imageTrace Outline Kontur @msg `globalAdjust` must be an object `globalAdjust` musi być obiektem @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5 @msg `stops` must be an array `stops` musi być tablicą +@msg `{key}` must be an array `{key}` musi być tablicą @msg `svg` must be an object of SVG options (see document.formats) `svg` musi być obiektem opcji SVG (patrz document.formats) @msg `{_1}` is no save command `{_1}` nie jest poleceniem zapisu @msg `{area}` is no recovery area name `{area}` nie jest nazwą obszaru odzyskiwania @@ -6351,6 +6352,10 @@ anchor Points: Punkty: @msg features lists at most {max} tags features może zawierać najwyżej {max} znaczników @msg unknown OpenType feature `{t}` nieznana funkcja OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musi być liczbą większą od 0 i nie większą niż {max} +@msg {label} must be at most {max} characters {label} może mieć najwyżej {max} znaków +@msg {label} must be one character {label} musi być jednym znakiem +@msg at most {max} tab stops najwyżej {max} tabulatorów +@msg unknown align `{other}` nieznane wyrównanie `{other}` @msg no character style `{name}` Nie znaleziono stylu znakowego `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..c4c9994027 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3792,6 +3792,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5 @msg `stops` must be an array `stops` deve ser uma matriz +@msg `{key}` must be an array `{key}` deve ser uma matriz @msg `svg` must be an object of SVG options (see document.formats) `svg` deve ser um objeto com opções de SVG (veja document.formats) @msg `{_1}` is no save command `{_1}` não é um comando de salvar @msg `{area}` is no recovery area name `{area}` não é o nome de uma área de recuperação @@ -6412,6 +6413,10 @@ anchor Points: Pontos: @msg features lists at most {max} tags features aceita no máximo {max} marcas @msg unknown OpenType feature `{t}` recurso OpenType desconhecido `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve ser um número maior que 0 e no máximo {max} +@msg {label} must be at most {max} characters {label} deve ter no máximo {max} caracteres +@msg {label} must be one character {label} deve ser um único caractere +@msg at most {max} tab stops no máximo {max} paradas de tabulação +@msg unknown align `{other}` alinhamento desconhecido `{other}` @msg no character style `{name}` Estilo de caractere `{name}` não encontrado # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..d3d3a0bd53 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4282,6 +4282,7 @@ axis Both Оба @msg `globalAdjust` must be an object `globalAdjust` должно быть объектом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5 @msg `stops` must be an array `stops` должно быть массивом +@msg `{key}` must be an array `{key}` должно быть массивом @msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats) @msg `{_1}` is no save command `{_1}` не является командой сохранения @msg `{area}` is no recovery area name `{area}` не является именем области восстановления @@ -6473,6 +6474,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features содержит не более {max} тегов @msg unknown OpenType feature `{t}` неизвестная функция OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} должно быть числом больше 0 и не более {max} +@msg {label} must be at most {max} characters {label} должно содержать не более {max} символов +@msg {label} must be one character {label} должно быть одним символом +@msg at most {max} tab stops не более {max} позиций табуляции +@msg unknown align `{other}` неизвестное выравнивание `{other}` @msg no character style `{name}` Стиль символов `{name}` не найден # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..7fb7252fab 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4155,6 +4155,7 @@ axis Both Båda @msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5 @msg `stops` must be an array `stops` måste vara en array +@msg `{key}` must be an array `{key}` måste vara en array @msg `svg` must be an object of SVG options (see document.formats) `svg` måste vara ett objekt med SVG-alternativ (se document.formats) @msg `{_1}` is no save command `{_1}` är inget sparkommando @msg `{area}` is no recovery area name `{area}` är inget namn på ett återställningsområde @@ -6543,6 +6544,9 @@ shape Open Öppen @msg features lists at most {max} tags features får innehålla högst {max} taggar @msg unknown OpenType feature `{t}` okänd OpenType-funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} måste vara ett tal över 0 och högst {max} +@msg {label} must be at most {max} characters {label} får innehålla högst {max} tecken +@msg {label} must be one character {label} måste vara ett tecken +@msg at most {max} tab stops högst {max} tabbstopp @msg no character style `{name}` Teckenformatet `{name}` hittades inte # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..7320e9ccf1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4035,6 +4035,7 @@ axis Both Her İkisi @msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır @msg `stops` must be an array `stops` bir dizi olmalıdır +@msg `{key}` must be an array `{key}` bir dizi olmalıdır @msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) @msg `{_1}` is no save command `{_1}` kaydetme komutu değil @msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir @@ -6452,6 +6453,10 @@ anchor Points: Noktalar: @msg features lists at most {max} tags features en fazla {max} etiket içerebilir @msg unknown OpenType feature `{t}` bilinmeyen OpenType özelliği `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0'ın üzerinde ve en fazla {max} olan bir sayı olmalıdır +@msg {label} must be at most {max} characters {label} en fazla {max} karakter olmalıdır +@msg {label} must be one character {label} tek bir karakter olmalıdır +@msg at most {max} tab stops en fazla {max} sekme durağı +@msg unknown align `{other}` bilinmeyen hizalama `{other}` @msg no character style `{name}` `{name}` karakter stili bulunamadı # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..bd1dd12694 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4280,6 +4280,7 @@ axis Both Обидві @msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5 @msg `stops` must be an array `stops` має бути масивом +@msg `{key}` must be an array `{key}` має бути масивом @msg `svg` must be an object of SVG options (see document.formats) `svg` має бути об’єктом параметрів SVG (див. document.formats) @msg `{_1}` is no save command `{_1}` не є командою збереження @msg `{area}` is no recovery area name `{area}` не є ім’ям області відновлення @@ -6104,6 +6105,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features містить не більше {max} тегів @msg unknown OpenType feature `{t}` невідома функція OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} має бути числом більше 0 і не більше {max} +@msg {label} must be at most {max} characters {label} має містити не більше {max} символів +@msg {label} must be one character {label} має бути одним символом +@msg at most {max} tab stops не більше {max} позицій табуляції +@msg unknown align `{other}` невідоме вирівнювання `{other}` @msg no character style `{name}` Стиль символів `{name}` не знайдено # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..ba35702883 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4414,6 +4414,7 @@ theme Appearance Giao diện @msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng @msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5 @msg `stops` must be an array `stops` phải là một mảng +@msg `{key}` must be an array `{key}` phải là một mảng @msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats) @msg `to` must be "corner" or "smooth" `to` phải là "corner" hoặc "smooth" @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` phải là "emBox", "capHeight", "xHeight" hoặc "icfBox" @@ -6105,6 +6106,10 @@ anchor Points: Điểm: @msg features lists at most {max} tags features chứa tối đa {max} thẻ @msg unknown OpenType feature `{t}` tính năng OpenType không xác định `{t}` @msg {label} must be a number above 0 and at most {max} {label} phải là một số lớn hơn 0 và tối đa {max} +@msg {label} must be at most {max} characters {label} chỉ được có tối đa {max} ký tự +@msg {label} must be one character {label} phải là một ký tự +@msg at most {max} tab stops tối đa {max} điểm dừng tab +@msg unknown align `{other}` căn chỉnh không xác định `{other}` @msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..b24163d7ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4102,6 +4102,7 @@ imageTrace Outline 轮廓 @msg `preserve` must be an object `preserve` 必须是对象 @msg `globalAdjust` must be an object `globalAdjust` 必须是对象 @msg `stops` must be an array `stops` 必须是数组 +@msg `{key}` must be an array `{key}` 必须是数组 @msg `{k}` must be [x, y] `{k}` 必须是 [x, y] @msg a gradient needs at least two stops 渐变至少需要两个色标 @msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出) @@ -6337,6 +6338,10 @@ anchor Points: 点: @msg features lists at most {max} tags features 最多包含 {max} 个标签 @msg unknown OpenType feature `{t}` 未知的 OpenType 特性 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必须是大于 0 且不超过 {max} 的数字 +@msg {label} must be at most {max} characters {label} 最多 {max} 个字符 +@msg {label} must be one character {label} 必须是一个字符 +@msg at most {max} tab stops 最多 {max} 个制表位 +@msg unknown align `{other}` 未知的对齐方式 `{other}` @msg no character style `{name}` 找不到字符样式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..373e7d8f53 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1902,6 +1902,7 @@ file-filter Templates 範本 @msg `preserve` must be an object `preserve` 必須是物件 @msg `globalAdjust` must be an object `globalAdjust` 必須是物件 @msg `stops` must be an array `stops` 必須是陣列 +@msg `{key}` must be an array `{key}` 必須是陣列 @msg `{k}` must be [x, y] `{k}` 必須是 [x, y] @msg a gradient needs at least two stops 漸層至少需要兩個色標 @msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供) @@ -6142,6 +6143,10 @@ anchor Points: 點: @msg features lists at most {max} tags features 最多包含 {max} 個標籤 @msg unknown OpenType feature `{t}` 未知的 OpenType 功能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必須是大於 0 且不超過 {max} 的數字 +@msg {label} must be at most {max} characters {label} 最多 {max} 個字元 +@msg {label} must be one character {label} 必須是一個字元 +@msg at most {max} tab stops 最多 {max} 個定位點 +@msg unknown align `{other}` 未知的對齊方式 `{other}` @msg no character style `{name}` 找不到字元樣式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 7e58a218a5..fb12e4fdc0 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -20,7 +20,7 @@ const ALIGNS: [(TabAlign, &str); 4] = [(TabAlign::Left, "⇥"), (TabAlign::Cente /// The name of alignment `a`'s button: its tool tip and what screen readers call it. A line of /// vertical type runs from top to bottom, so in vertical type a Left stop is named Top-Justified /// and a Right stop is named Bottom-Justified. -fn align_name(a: TabAlign, vertical: bool) -> &'static str { +pub(crate) fn align_name(a: TabAlign, vertical: bool) -> &'static str { match (a, vertical) { (TabAlign::Left, false) => tl!("Left-Justified Tab"), (TabAlign::Left, true) => tl!("Top-Justified Tab"), diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..a8141cfa5b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7780,7 +7780,9 @@ touches (a caret, `start == end`, touches its paragraph). Without a range they c targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or -`alignOn` value that is not a string, and an empty `alignOn` value. +`alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than +8 characters, a position outside 0 to 100000 pt, and more than 256 stops. It sorts the stops by position and keeps the +first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in @@ -7797,7 +7799,7 @@ is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The reply lists the names `added` and `existing` in the document and the names `skipped` in the file (a style whose -attributes do not read or are out of the range `charStyle.new` takes, or whose name is empty); `textStyle.loadAll` +attributes do not read or are out of the range `charStyle.new` or `paraStyle.new` takes, or whose name is empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`, and `charStyle.load` and `paraStyle.load` list only the styles of their own kind. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as @@ -7862,12 +7864,19 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `composition`, `hyphenation` or +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation` or `justification`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and `space_after`, typed in the General unit of Preferences or with a unit of their own. +- Tabs: `tabs`, the style's stops, each `{position, align, leader, alignOn}`: `position` typed like the distances + above, `align` `left`, `center`, `right` or `decimal`, `leader` up to 8 characters, and `alignOn` one character + (used by decimal stops). The section edits the selected stop with Alignment, X: (Y: for vertical type), Leader: + and Align On:. Add puts a left stop 36 pt past the furthest stop (at 36 pt when there is none, and at most at + 100000 pt); it is disabled at 256 stops or when a stop is already at that position. Delete Tab removes the selected + stop, and Clear All Tabs leaves the style with no stops, which it then sets. OK sorts the stops by + position; of two stops at one position it keeps the one edited last. - Composition: `composer` (`singleLine` or `everyLine`) and the `roman_hanging` checkbox. - Hyphenation: the `hyphenate` checkbox, `words_longer_than`, `after_first`, `before_last`, `hyphen_limit`, `hyphenation_zone` and the `hyphenate_capitalized` checkbox. The options are inactive while Hyphenation is off, and @@ -7878,15 +7887,17 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `co A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes -the dialog does not show, such as tabs and character attributes, stay as they are. +the dialog does not show, such as character attributes, stay as they are. `paraStyle.new`, `paraStyle.setAttrs` and `paraStyle.edit` reject a changed indent, space before or space after outside -1296 to 1296 pt, Words Longer Than outside 1 to 25, After First or Before Last outside 1 to 15, Hyphen Limit outside 0 to 25, Hyphenation Zone outside 0 to 1296 pt, Word Spacing outside 0 to 1000%, Letter Spacing outside -100 to 500%, Glyph Scaling outside 50 to 200%, Minimum, Desired and Maximum values out of that order, Auto Leading outside 0 to -500%, and a tab position outside 0 to 100000 pt. A value the style already has is kept. An invalid value leaves the +500%, and changed tabs with more than 256 stops, or with a changed stop whose position is outside 0 to 100000 pt or +whose leader is longer than 8 characters. They sort changed tabs by position and keep the first of stops at one +position. A value the style already has is kept. An invalid value leaves the style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the -five distances, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and +five distances, Tabs removes `tabs`, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and `hyphenation`, and Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..54fec89283 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -641,7 +641,8 @@ since 2026-10-01. - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). - **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`), as the panel menu's item does. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. - **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. -- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. Tabs and the character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. The character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, Tabs:** the Tabs section of Paragraph Style Options and New Paragraph Style lists a style's tab stops and edits the selected stop's Alignment (Left, Center, Right or Decimal), position (X:, or Y: for vertical type), Leader and Align On character; Add, Delete Tab and Clear All Tabs add a stop 36 pt past the furthest, remove the selected one, or leave the style with no stops. OK sorts the stops by position, keeps the stop edited last of two at one position, and saves one undo step; Preview and Cancel restore the paragraphs, Reset Panel removes the style's tabs, and a position outside 0 to 100000 pt, a leader longer than 8 characters or more than 256 stops keeps the dialog open. Tests `dialogs::para_style_options::tests::tabs_section_edits_the_style_stops` and `tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step`. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. From 5d8065e9296e475f07f565cb055a9db6d56bb180 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:04:22 -0800 Subject: [PATCH 100/126] 3D (Classic): Revolve (Classic) sweeps a profile with Angle, Cap and Offset and the classic surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect › 3D and Materials › 3D (Classic) › Revolve (Classic)… sweeps the profile round a vertical axis, as Revolve does, and draws every face with the Wireframe, No Shading, Diffuse and Plastic surfaces of Extrude & Bevel (Classic). - `effect.apply` with `threeD.classicRevolve`: Angle (0 to 360), Cap, Offset (0 to 1000 pt) from the left or right edge of the profile's bounds, the Position rotation and Perspective, and the classic lights, Ambient Light, highlight, Blend Steps and Shading Color. - Groups and layers revolve about one shared axis, and the effect also goes on a fill or stroke, as Revolve's does. - `effect.lightingPreset` refuses it, as it refuses Extrude & Bevel (Classic). - Revolve faces carry their view-space normal, and `three_d::revolve_facets` hands them to the classic surface code. - Unknown parameters, numbers out of range and bad flags, edges, surfaces and colors are refused with an error. --- crates/effects/src/catalog.rs | 12 +- crates/effects/src/lib.rs | 4 +- crates/effects/src/revolve.rs | 127 +++++++++---- crates/effects/src/revolve/classic.rs | 176 ++++++++++++++++++ crates/effects/src/solids.rs | 12 +- crates/effects/src/solids/classic.rs | 16 +- crates/engine/src/cmd/effectcmd.rs | 12 +- .../cmd/effectcmd/lighting_presets_tests.rs | 15 ++ crates/three-d/src/lib.rs | 10 + crates/three-d/src/solid.rs | 18 ++ crates/three-d/src/tests/classic.rs | 29 +++ crates/three-d/src/visibility.rs | 1 + crates/ui-egui/src/dialogs/effect.rs | 15 +- crates/ui-egui/src/dialogs/revolve.rs | 19 +- crates/ui-egui/src/dialogs/revolve/tests.rs | 43 +++++ crates/ui-egui/src/dialogs/revolve_gizmo.rs | 14 +- crates/ui-egui/src/dialogs/three_d_classic.rs | 31 ++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/tests.rs | 16 ++ docs/effects-parity.md | 4 +- docs/gaps.md | 2 +- docs/mcp.md | 28 ++- docs/three-d.md | 10 +- 49 files changed, 550 insertions(+), 91 deletions(-) create mode 100644 crates/effects/src/revolve/classic.rs diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5c18f2d5f0..81c09494b9 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,6 @@ -use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; +use super::{ + CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap, +}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] @@ -81,6 +83,7 @@ fn lengths_of(id: &str) -> Lengths { "threeD.revolve" => always(&["offset"]), "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), "threeD.classicExtrude" => always(&["depth", "bevelHeight"]), + "threeD.classicRevolve" => always(&["offset"]), "threeD.inflate" => always(&["depth"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), @@ -207,6 +210,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), ), + g( + CLASSIC_REVOLVE, + "Revolve (Classic)…", + &["Effect", "3D and Materials", "3D (Classic)"], + "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", + json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + ), g( "convertToShape.rectangle", "Rectangle…", diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..f92515580c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -83,7 +83,9 @@ pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; -pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in}; +pub use revolve::{ + CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, +}; pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..e7fd5e5f22 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -1,4 +1,5 @@ -//! Revolve as live, shaded vector art, shared by the canvas and every exporter. +//! Revolve and Revolve (Classic) as live, shaded vector art, shared by the canvas and every +//! exporter. use std::sync::Arc; use serde_json::Value; @@ -13,6 +14,14 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; pub const REVOLVE: &str = "threeD.revolve"; +mod classic; +pub use classic::CLASSIC_REVOLVE; + +/// Is `id` Revolve or Revolve (Classic), the effects this module draws? +pub fn is_revolve(id: &str) -> bool { + id == REVOLVE || id == CLASSIC_REVOLVE +} + /// Most bytes the copies of the object's paint in the faces may take together. Each face and the /// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of /// thousands of faces took gigabytes; past this the source path is drawn. @@ -55,7 +64,7 @@ pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) - } fn visible(fx: &[Effect]) -> bool { - fx.iter().any(|e| e.visible && e.id == REVOLVE) + fx.iter().any(|e| e.visible && is_revolve(&e.id)) } pub fn has_revolve(n: &Node) -> bool { @@ -88,6 +97,17 @@ pub fn revolve_options(p: &Value) -> Revolve { } } +/// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with +/// parameters `p` sweeps its surface with. +fn options_of(id: &str, p: &Value) -> Revolve { + if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) } +} + +/// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with. +fn fx_options(fx: &Effect) -> Revolve { + options_of(&fx.id, &merged_params(&fx.id, &fx.params)) +} + /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { @@ -97,11 +117,16 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { /// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group /// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now. pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { - validate_revolve_params(p)?; + validate_in(doc, n, REVOLVE, p) +} + +/// [`validate_revolve_in`] for Revolve or Revolve (Classic), `id`. +pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_params_of(id, p)?; let n = &*resolve_for(doc, n, "Revolve", false)?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. - let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + let before = effects.iter().position(|e| is_revolve(&e.id)).and_then(|i| effects.get(..i)).unwrap_or(effects); if is_container(n) { const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; let mut shapes = members(n, "Revolve")?; @@ -110,7 +135,7 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul } reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or(EMPTY)?; - let options = shared_options(&shapes, revolve_options(p))?; + let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); @@ -123,8 +148,8 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?; - check_shadow(p, [&path], b, revolve_options(p)) + check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + check_shadow(p, [&path], b, options_of(id, p)) } /// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast @@ -138,14 +163,21 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator<Item = &'a PathData>, b: } /// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of -/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve, -/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level -/// Revolve draws in place of those surfaces. +/// appearance item `target` of `n` ([`validate_item_of`]). +#[cfg(test)] pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { + validate_item_of(n, REVOLVE, p, target, index) +} + +/// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index +/// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke +/// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible +/// object-level Revolve draws in place of those surfaces. +pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { - return validate_revolve(n, p); + return validate_in(None, n, id, p); } - validate_revolve_params(p)?; + validate_params_of(id, p)?; const EMPTY: &str = "Revolve requires a shape with at least two anchors"; let (path, _) = object_profile(n, "Revolve")?; if !has_two_anchors(&path) { @@ -156,13 +188,13 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u let mut copies = 0; for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); - let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; - let params = match at.and_then(|at| effects.get(at)) { - _ if i == target => p.clone(), - Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params), + let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) }; + let (kind, params) = match at.and_then(|at| effects.get(at)) { + _ if i == target => (id, p.clone()), + Some(fx) if crate::paints(item) => (fx.id.as_str(), merged_params(&fx.id, &fx.params)), _ => continue, }; - let options = revolve_options(¶ms); + let options = options_of(kind, ¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; @@ -217,6 +249,16 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } +/// [`validate_revolve_params`] for Revolve or Revolve (Classic), `id`; Revolve (Classic) also takes +/// only its own parameters, each in its range. +pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { + validate_revolve_params(p)?; + if id == CLASSIC_REVOLVE { + classic::validate(p)?; + } + Ok(()) +} + fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } @@ -226,7 +268,7 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when /// the surface it is cast from is past the limits. fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result<Option<Node>, String> { - let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) }; + let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) }; let (_, opacity, blend) = paint_of(item); if opacity <= 0.0 { return Ok(None); @@ -242,7 +284,7 @@ fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: /// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the /// limits. fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result<Vec<Arc<Node>>, String> { - let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) }; + let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) }; let mut caster = vectorcraft_three_d::ShadowCaster::new(); let mut cast = vec![]; for s in shapes { @@ -259,7 +301,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R } /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts -/// the bytes of the paint copies of every surface of the object. +/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face +/// with its classic surface, as Extrude & Bevel (Classic) does. #[allow(clippy::too_many_arguments)] fn surface( n: &Node, @@ -275,6 +318,16 @@ fn surface( ) -> Option<(Vec<f64>, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. + if fx.id == CLASSIC_REVOLVE { + let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; + *copies = copies.saturating_add(paint_copies(facets.len(), &paint)); + if *copies > MAX_PAINT_COPIES { + return None; + } + let depths = facets.iter().map(|f| f.depth).collect(); + let p = merged_params(&fx.id, &fx.params); + return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); @@ -371,8 +424,7 @@ fn surface( fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option<Node> { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - let options = revolve_options(&merged_params(REVOLVE, &fx.params)); - surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -429,10 +481,10 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> { let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let (paint, opacity, _) = paint_of(item); - let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, fx_options(fx))?; Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) }; - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let before = n.appearance.effects.get(..index).unwrap_or_default(); let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, before, &ctx); @@ -445,7 +497,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); @@ -461,13 +513,13 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate:: .appearance .effects .iter() - .position(|e| e.visible && e.id == REVOLVE) + .position(|e| e.visible && is_revolve(&e.id)) .ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let mut shapes = members(n, "Revolve")?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let options = shared_options(&shapes, fx_options(fx))?; let mut out = vec![]; for s in &shapes { let Some(item) = surface_item(&s.items) else { continue }; @@ -491,7 +543,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { let mut children = vec![]; let mut copies = 0; let mut remaining = n.appearance.effects.clone(); - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = n.appearance.effects.get(index)?; let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); // An object's surface uses its top visible fill, falling back to its stroke. @@ -500,7 +552,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? { children.push(Arc::new(shadow)); } @@ -511,7 +563,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { .effects .iter() .enumerate() - .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index)) + .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) .map(|(_, e)| e.clone()) .collect(); } else { @@ -520,14 +572,14 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; - art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() - && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? { shadow.appearance.effects = art.appearance.effects.clone(); shadows.push(Arc::new(shadow)); @@ -666,12 +718,12 @@ fn overlaps(a: Rect, b: Rect) -> bool { /// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option<Node> { - let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; + let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?; let fx = n.appearance.effects.get(index)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let b = union_bounds(&shapes)?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + let options = shared_options(&shapes, fx_options(fx)).ok()?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = 0; let mut surfaces = vec![]; @@ -689,7 +741,10 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { if surfaces.is_empty() { return None; } - let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + // A classic surface whose gradient has transparent stops carries an opacity mask, and stays whole. + let plain = surfaces.iter().all(|(depths, s, _)| { + s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()) + }); let children = if !plain { scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) } else { diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs new file mode 100644 index 0000000000..c9a73ddd82 --- /dev/null +++ b/crates/effects/src/revolve/classic.rs @@ -0,0 +1,176 @@ +//! Revolve (Classic): the surface Revolve sweeps from a path, drawn with the classic surfaces of +//! Extrude & Bevel (Classic) (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend +//! steps and shading color. +use serde_json::Value; +use vectorcraft_three_d::Revolve; + +pub const CLASSIC_REVOLVE: &str = "threeD.classicRevolve"; + +/// The numeric parameters: (key, lowest, highest, whole numbers only). +const RANGES: [(&str, f64, f64, bool); 13] = [ + ("angle", 0.0, 360.0, false), + ("offset", 0.0, 1000.0, false), + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("highlightIntensity", 0.0, 100.0, false), + ("highlightSize", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +const FLAGS: [&str; 2] = ["cap", "lightBehind"]; + +/// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge, +/// rotation and perspective, built unshaded, since the classic surfaces light its faces. +pub(crate) fn options(p: &Value) -> Revolve { + Revolve { shade: false, smooth_shading: false, lights: None, light_color: [1.0; 3], ..super::revolve_options(p) } +} + +/// Check Revolve (Classic)'s parameters `p` (its defaults filled in) beyond the checks it shares +/// with Revolve: every key is one of its parameters, numbers are in range, flags are true or false, +/// and the surface and shading color are named. +pub(crate) fn validate(p: &Value) -> Result<(), String> { + crate::solids::classic::validate_classic(CLASSIC_REVOLVE, p, &RANGES, &FLAGS) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::color::{Color, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId}; + use vectorcraft_geom::{BezPath, PathData}; + + use super::*; + + const BLUE: Color = Color::Rgb { r: 0.2, g: 0.4, b: 0.8 }; + + /// Half a vase's profile, open, filled blue. + fn profile() -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)) + } + + fn revolved(params: Value) -> Node { + let mut n = profile(); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + } + + /// The faces of the surface of `art`, back to front. + fn faces(art: &Node) -> Vec<&Node> { + art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect() + } + + fn fill(face: &Node) -> Option<Color> { + face.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color(), + AppearanceItem::Stroke(_) => None, + }) + } + + fn facets(params: &Value) -> usize { + let n = profile(); + let path = n.path_data().unwrap(); + let p = crate::merged_params(CLASSIC_REVOLVE, params); + vectorcraft_three_d::revolve_facets(path, path.bounds().unwrap(), options(&p)).unwrap().len() + } + + #[test] + fn plastic_shades_every_face_of_the_swept_surface_in_the_fill() { + let art = revolved(json!({})); + let drawn = faces(&art); + assert_eq!(drawn.len(), facets(&json!({})), "one face for each facet of the surface"); + let colors: Vec<[f32; 3]> = drawn.iter().map(|f| fill(f).unwrap().to_rgb()).collect(); + // Lit from one side, the faces take more than one tone, and none is another hue: a face is + // the blue darkened toward black, lightened toward white, or both. + let distinct = colors.iter().filter(|c| colors.iter().any(|d| (c[0] - d[0]).abs() > 0.02)).count(); + assert!(distinct > 0, "{colors:?}"); + // Diffuse has no highlight, so every face is the blue darkened toward black. + let art = revolved(json!({"surface": "diffuse"})); + for face in faces(&art) { + let [r, g, b] = fill(face).unwrap().to_rgb(); + assert!(b <= 0.8 + 1e-6 && (g * 2.0 - b).abs() < 1e-4 && (r * 4.0 - b).abs() < 1e-4, "{r} {g} {b}"); + } + } + + #[test] + fn wireframe_strokes_the_edges_of_every_face_and_no_shading_keeps_the_paint() { + let art = revolved(json!({"surface": "wireframe"})); + let faces_drawn = faces(&art); + assert_eq!(faces_drawn.len(), facets(&json!({"surface": "wireframe"}))); + for face in faces_drawn { + match face.appearance.items.as_slice() { + [AppearanceItem::Stroke(s)] => assert_eq!((s.paint.color(), s.width), (Some(BLUE), 0.5)), + items => panic!("a wireframe face is one stroke: {items:?}"), + } + } + for face in faces(&revolved(json!({"surface": "none"}))) { + assert_eq!(fill(face), Some(BLUE)); + } + } + + #[test] + fn angle_offset_edge_and_cap_shape_the_surface() { + let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); + // Seen from the front, the full turn is twice the profile's width across, about its left edge. + assert!((full.width() - 60.0).abs() < 2.0, "{full:?}"); + let ring = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "offset": 20})).visual_bounds().unwrap(); + assert!((ring.width() - 100.0).abs() < 2.0, "{ring:?}"); + // About its right edge the axis is at x 130, so the surface reaches x 160. + let right = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "edge": "right"})).visual_bounds().unwrap(); + assert!((right.x1 - 160.0).abs() < 2.0, "{right:?}"); + assert!(facets(&json!({"angle": 90})) < facets(&json!({}))); + // The profile's ends lie on the axis, so they close nothing until an offset moves them off it. + assert!(facets(&json!({"cap": false, "offset": 10})) < facets(&json!({"offset": 10})), "hollow leaves out the end caps"); + } + + #[test] + fn a_group_revolves_its_members_about_one_shared_axis() { + let mut b = BezPath::new(); + b.move_to((150.0, 60.0)); + b.line_to((170.0, 60.0)); + b.line_to((170.0, 140.0)); + let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(profile()), std::sync::Arc::new(outer)]); + g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).unwrap()); + let art = crate::revolve_art(&g).unwrap(); + // The axis is at the left edge of both members together, x 100, so the outer one reaches 70 + // out on either side of it. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x0 - 30.0).abs() < 2.0 && (bounds.x1 - 170.0).abs() < 2.0, "{bounds:?}"); + let mut colors = vec![]; + art.walk(&mut |n| colors.extend(fill(n).map(|c| c.to_rgb()))); + assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn"); + } + + #[test] + fn bad_parameters_are_refused() { + let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p)); + assert_eq!(check(json!({})), Ok(())); + for (p, error) in [ + (json!({"segments": 32}), "unknown parameter `segments`"), + (json!({"angle": 400}), "`angle` must be between 0 and 360"), + (json!({"offset": 1001}), "`offset` must be between 0 and 1000"), + (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"), + (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"), + (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"surface": "glossy"}), "`surface` must be one of"), + (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), + (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"edge": "middle"}), "Revolve edge must be left or right"), + (json!({"cap": "yes"}), "Revolve cap must be a boolean"), + ] { + let e = check(p.clone()).unwrap_err(); + assert!(e.contains(error), "{p}: {e}"); + } + } +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..7502dad469 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -14,7 +14,7 @@ use vectorcraft_doc::color::Color; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry}; -mod classic; +pub(crate) mod classic; pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; mod source; @@ -28,9 +28,9 @@ pub const ROTATE: &str = "threeD.rotate"; /// The 3D effects this module draws (Revolve has its own). pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE]; -/// Is `id` one of the 3D and Materials effects (Revolve included)? +/// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)? pub fn is_three_d(id: &str) -> bool { - id == crate::REVOLVE || SOLIDS.contains(&id) + crate::revolve::is_revolve(id) || SOLIDS.contains(&id) } /// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). @@ -417,7 +417,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) } + if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) } } /// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of @@ -436,7 +436,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va Some(_) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } - Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)), None => validate_three_d_in(doc, n, id, p), } @@ -446,7 +446,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) } + if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) } } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 730fb8e53b..ef2a39e015 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -104,8 +104,16 @@ const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces" /// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and /// `shadingCustom` name a surface, a shading color and a #rrggbb color. pub fn validate(p: &Value) -> Result<(), String> { + validate_classic(CLASSIC_EXTRUDE, p, &RANGES, &FLAGS) +} + +/// Check the parameters `p` of classic 3D effect `id` (its defaults filled in): every key is one of +/// its parameters, the numbers `ranges` lists, as (key, lowest, highest, whole numbers only), are in +/// range, the `flags` are true or false, and `surface`, `shadingColor` and `shadingCustom` name a +/// surface, a shading color and a #rrggbb color. +pub(crate) fn validate_classic(id: &str, p: &Value, ranges: &[(&str, f64, f64, bool)], flags: &[&str]) -> Result<(), String> { let Some(map) = p.as_object() else { return Ok(()) }; - let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null); + let defaults = crate::default_params(id).unwrap_or(Value::Null); let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default(); for key in map.keys() { if !known.contains(&key.as_str()) { @@ -113,7 +121,7 @@ pub fn validate(p: &Value) -> Result<(), String> { return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); } } - for (key, min, max, whole) in RANGES { + for &(key, min, max, whole) in ranges { let Some(v) = map.get(key) else { continue }; let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) }; if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) { @@ -123,7 +131,7 @@ pub fn validate(p: &Value) -> Result<(), String> { return Err(format!("`{key}` must be between {min} and {max} (got {x})")); } } - for key in FLAGS { + for &key in flags { if map.get(key).is_some_and(|v| !v.is_boolean()) { return Err(format!("`{key}` must be true or false")); } @@ -161,7 +169,7 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color { /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by /// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by /// its own transparency, so the shading follows the gradient's coverage. -pub(super) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { +pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { let shading = classic_shading(p); let lights = lights(p); let shade = shading_color(p); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 30d927a966..566c3cd24a 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic) or Revolve (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -656,12 +656,10 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> { if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); } - // Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options. - if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) { - return Err(bad( - C, - format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0), - )); + // The classic 3D effects have no lighting presets; their lights are set in their own options. + if let Some(e) = effect.filter(|e| e.id == effects::CLASSIC_EXTRUDE || e.id == effects::CLASSIC_REVOLVE) { + let name = effects::effect_info(&e.id).map_or("This effect", |info| info.label.trim_end_matches('…')); + return Err(bad(C, format!("object {}: {name} at index {index} has no lighting presets; set its lights with effect.setParams", id.0))); } } let mut options = p.clone(); diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs index 812ce09664..80d13dc26c 100644 --- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs +++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs @@ -115,3 +115,18 @@ fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params; assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0))); } + +#[test] +fn lighting_presets_refuse_revolve_classic() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("path.create", &json!({"d":"M100 40 L130 60 L130 140 L100 160"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color":"#3366cc"})).unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicRevolve","item":null,"params":{"surface":"diffuse"}})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"topLeft"})).unwrap_err().to_string(); + assert!(err.contains(&format!("object {id}: Revolve (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 1530caf105..2d945c1439 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -341,6 +341,9 @@ impl Revolve { pub struct Face { pub points: Vec<Point>, pub depth: f64, + /// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the + /// side the viewer sees. + pub normal: [f64; 3], pub brightness: f64, pub gradient: Option<ShadeGradient>, /// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp. @@ -453,6 +456,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi Ok(Some(Face { points, depth: vertices.iter().map(|v| v.2).sum::<f64>() / vertices.len() as f64, + normal: [normal.0, normal.1, normal.2], brightness, gradient, illumination, @@ -470,6 +474,12 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa revolve_faces(path, FillRule::NonZero, bounds, options, None) } +/// [`revolve`]'s faces as facets, back to front, for the classic surfaces: each keeps its +/// view-space normal, and its brightness and illumination are those of `options`. +pub fn revolve_facets(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Facet>, String> { + Ok(revolve(path, bounds, options)?.into_iter().map(Facet::of_face).collect()) +} + const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index d486f443cf..18c9959b19 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -294,6 +294,24 @@ impl Facet { } PathData::from_bezpath(&bp) } + + /// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting. + /// A face that joins the profile's ends round the axis is a side; a cap is an end. + pub(crate) fn of_face(face: crate::Face) -> Self { + Facet { + contours: vec![face.points], + depth: face.depth, + brightness: face.brightness, + illumination: face.illumination, + normal: face.normal, + rgb_gradient: None, + outline: face.outline, + side: !face.cap, + away: false, + #[cfg(test)] + view: vec![], + } + } } fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 { diff --git a/crates/three-d/src/tests/classic.rs b/crates/three-d/src/tests/classic.rs index 1d807b4a3e..f460297453 100644 --- a/crates/three-d/src/tests/classic.rs +++ b/crates/three-d/src/tests/classic.rs @@ -94,3 +94,32 @@ fn surfaces_round_trip_through_their_ids() { assert_eq!(ClassicSurface::parse("glossy"), None); assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic); } + +#[test] +fn revolve_facets_keep_each_face_normal_turned_to_the_viewer() { + // A cylinder of radius 30 about the left edge, with closed ends, tipped toward the viewer. + let mut b = BezPath::new(); + b.move_to((100.0, 0.0)); + b.line_to((130.0, 0.0)); + b.line_to((130.0, 100.0)); + b.line_to((100.0, 100.0)); + let path = PathData::from_bezpath(&b); + let options = Revolve { rotation_x: -30.0, shade: false, smooth_shading: false, ..Revolve::default() }; + let faces = revolve(&path, path.bounds().unwrap(), options).unwrap(); + let facets = revolve_facets(&path, path.bounds().unwrap(), options).unwrap(); + assert_eq!(facets.len(), faces.len()); + for (facet, face) in facets.iter().zip(&faces) { + let [x, y, z] = facet.normal; + assert!(((x * x + y * y + z * z).sqrt() - 1.0).abs() < 1e-9, "unit length: {:?}", facet.normal); + assert!(z >= 0.0, "turned to the viewer: {:?}", facet.normal); + assert_eq!((facet.depth, facet.side), (face.depth, !face.cap)); + } + // Tipped 30°, the wall faces nearest the viewer turn 30° down from it, and the flat top and bottom + // (the profile's horizontal edges swept round) face up and out at 30°. + let (s, c) = 30f64.to_radians().sin_cos(); + assert!(facets.iter().any(|f| f.normal[2] > c - 0.01 && (f.normal[1] - s).abs() < 0.01), "{facets:?}"); + assert!(facets.iter().any(|f| (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6), "{facets:?}"); + // Moved off the axis, the profile's open ends close with caps, which are ends rather than sides. + let ring = revolve_facets(&path, path.bounds().unwrap(), Revolve { offset: 10.0, ..options }).unwrap(); + assert!(ring.iter().any(|f| !f.side && (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6)); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index f66187fcf9..db8f5b0df7 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -293,6 +293,7 @@ mod tests { Face { points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], depth: brightness, + normal: [0.0, 0.0, 1.0], brightness, gradient: None, illumination: [brightness; 3], diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 3a58ee3a9e..fdf6eb860b 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -382,25 +382,27 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> { /// The parameters whose dialogs list their values in their own bodies, as (label, value), each /// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI -/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d" -/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's +/// language: the edge of both Revolves, the bevel of both Extrude & Bevels (Rounded translated in +/// the "3d" context first), the surface and shading color of the classic 3D effects, every Warp style's /// orientation, Rasterize's Color Model (its `document` value labeled /// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and /// the channel of Levels and Curves. pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'static str, &'static str)>> { let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s); Some(match (effect, key) { - (vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], + (vectorcraft_effects::REVOLVE | vectorcraft_effects::CLASSIC_REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], ( vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE, "shadowPosition", ) => { vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")] } - (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "surface") => { vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] } - (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")], + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor") => { + vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")] + } (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ (tl!("None"), "none"), (tl!("Classic"), "classic"), @@ -965,6 +967,9 @@ mod tests { (vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"), (vectorcraft_effects::CLASSIC_EXTRUDE, "surface"), (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"), + (vectorcraft_effects::CLASSIC_REVOLVE, "edge"), + (vectorcraft_effects::CLASSIC_REVOLVE, "surface"), + (vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index b0cac0c011..925a593c91 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,12 +1,12 @@ //! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, -//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the -//! rotation and light gizmos live on the artboard. All document changes run through the effect +//! and Extrude & Bevel (Classic) and Revolve (Classic), whose own controls are in +//! `three_d_classic`. The object and the rotation and light gizmos live on the artboard. All document changes run through the effect //! dialog's existing preview/commit commands. use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; use crate::state::Dialog; use crate::theme::Tokens; @@ -145,11 +145,12 @@ pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) } -/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs -/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on -/// Lighting. +/// Extrude & Bevel, Inflate, Extrude & Bevel (Classic) and Revolve (Classic): Object, Rotation and +/// Lighting tabs (Rotate has no Object tab). The rotation gizmo shows on the first two, the light +/// gizmo on Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE; + let classic = id == CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE; + let object = id == EXTRUDE || id == INFLATE || classic; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); @@ -169,7 +170,7 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { - if id == CLASSIC_EXTRUDE { + if classic { super::three_d_classic::surface_controls(ui, d); } else { lighting_controls(ui, d); @@ -178,6 +179,8 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & let unit = app.session.general_unit(); if id == CLASSIC_EXTRUDE { super::three_d_classic::object_controls(ui, d, unit); + } else if id == CLASSIC_REVOLVE { + super::three_d_classic::revolve_controls(ui, d, unit); } else if id == EXTRUDE { extrude_controls(ui, d, unit); } else { diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index ba11d8666c..8a2ffb3066 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -379,6 +379,49 @@ fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() { assert_eq!(h.app.session.doc().unwrap().doc, before); } +/// Revolve (Classic): its angle, cap, offset and axis side on the Object tab, Position on the +/// Rotation tab, and the classic surface on the Lighting tab, without Draw Hidden Faces; OK keeps +/// the edit as one undo step. +#[test] +fn classic_revolve_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_REVOLVE; + let mut h = Harness::with("M100 40 L130 60 L130 140 L100 160", CLASSIC_REVOLVE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Angle", "Cap", "Offset", "Axis side", "Left"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Segments" || t == "Revolve angle" || t == "Depth"), "the other effects' options stay out"); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation")); + h.settle(); + for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + h.click(h.rect("Lighting").center()); + h.settle(); + for text in ["Surface", "Plastic", "Light Intensity", "Ambient Light", "Highlight Size", "Blend Steps", "Shading Color"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Draw Hidden Faces"), "every face is drawn"); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("edge".into(), json!("right")); + d.fields.insert("offset".into(), json!(12.0)); + d.fields.insert("surface".into(), json!("wireframe")); + h.settle(); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_REVOLVE); + assert_eq!( + (fx.params["edge"].clone(), fx.params["offset"].as_f64(), fx.params["surface"].clone()), + (json!("right"), Some(12.0), json!("wireframe")) + ); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 6ef51d38ed..5e49e4d9ea 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -1,9 +1,9 @@ //! Artboard rotation and light controls for the active 3D effect dialog (Extrude & Bevel, Revolve, -//! Inflate, Rotate). Gestures edit fields; +//! Inflate, Rotate and the classic 3D effects). Gestures edit fields; //! the existing effect preview applies commands and owns OK, Cancel and undo. use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2}; use serde_json::json; -use vectorcraft_effects::REVOLVE; +use vectorcraft_effects::{CLASSIC_REVOLVE, REVOLVE}; use vectorcraft_geom::Point; use vectorcraft_three_d::Revolve; @@ -20,10 +20,11 @@ pub(crate) fn lighting(d: &Dialog) -> bool { d.str("__gizmo") == "light" } -/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic +/// Does the effect take light: Shading on, or for the classic 3D effects a Diffuse or Plastic /// surface? fn lit(d: &Dialog) -> bool { - if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE { + let id = d.str("__effect"); + if id == vectorcraft_effects::CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE { matches!(d.str("surface").as_str(), "diffuse" | "plastic") } else { d.bool("shade") @@ -97,8 +98,9 @@ impl Geometry { let bounds = node.geometric_bounds()?; let xf = Xf::new(app.canvas_rect?, app.view()?); let options = super::revolve::light_options(d); - // Revolve turns about its axis; the others about the centre of the art's bounds. - let origin = if d.str("__effect") == REVOLVE { options.rotation_origin(bounds) } else { bounds.center() }; + // Both Revolves turn about their axis; the others about the centre of the art's bounds. + let id = d.str("__effect"); + let origin = if id == REVOLVE || id == CLASSIC_REVOLVE { options.rotation_origin(bounds) } else { bounds.center() }; let center = xf.to_screen(origin); if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) { return None; diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index 06d034fce4..6c03163f05 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,11 +1,11 @@ -//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options -//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on -//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect -//! shares. +//! The own controls of Extrude & Bevel (Classic) and Revolve (Classic) in the compact 3D dialog: the +//! extrude and bevel options, or the revolve options, on the Object tab, and the surface with its +//! lights, highlight, blend steps and shading color on the Lighting tab. Position (presets, +//! rotation, perspective) is the Rotation tab every 3D effect shares. use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::CLASSIC_EXTRUDE; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE}; use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; use crate::state::Dialog; @@ -25,8 +25,23 @@ pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { }); } +/// Revolve (Classic)'s angle, cap and offset, and the edge of the profile's bounds the axis is at. +pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + heading(ui, tl!("Object")); + number(ui, d, "angle", tl!("Angle"), (0.0, 360.0), "°", 1, None); + choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]); + number(ui, d, "offset", tl!("Offset"), (0.0, 1000.0), "", 1, Some(unit)); + let sides: Vec<(Value, &str)> = super::effect::own_choices(CLASSIC_REVOLVE, "edge", false) + .unwrap_or_default() + .into_iter() + .map(|(label, value)| (json!(value), label)) + .collect(); + choice(ui, d, "edge", tl!("Axis side"), &sides); +} + /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only), Blend Steps and Shading Color; Draw Hidden Faces last. +/// only), Blend Steps and Shading Color; Draw Hidden Faces last, for Extrude & Bevel (Classic) +/// (Revolve (Classic) draws every face). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { choice(ui, d, "surface", tl!("Surface"), &options("surface")); let surface = d.str("surface"); @@ -52,7 +67,9 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { shading_custom(ui, d); } }); - toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); + if d.str("__effect") == CLASSIC_EXTRUDE { + toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); + } } /// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 05756a1af6..9e1883d94a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1119,6 +1119,7 @@ font-technology Variable Променливи Extrude & Bevel… Избутване и фасетка… 3D (Classic) 3D (класически) Extrude & Bevel (Classic)… Избутване и фасетка (класически)… + Revolve (Classic)… Завъртане (класическо)… Surface Повърхност Wireframe Телена рамка No Shading Без засенчване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a9e705cfac..9c769fbf79 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -483,6 +483,7 @@ Extrude & Bevel… Istiskivanje i zakošenje… 3D (Classic) 3D (klasično) Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… + Revolve (Classic)… Rotaciono tijelo (klasično)… Surface Površina Wireframe Žičani model No Shading Bez sjenčenja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5341805a73..3b36368c64 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1573,6 +1573,7 @@ antiAlias Sharp Nítid Extrude & Bevel… Extrudeix i bisella… 3D (Classic) 3D (clàssic) Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… + Revolve (Classic)… Revoluciona (clàssic)… Surface Superfície Wireframe Filferro No Shading Sense ombra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9d6fd981e6..03aaedff30 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -492,6 +492,7 @@ Extrude & Bevel… Vysunutí a zkosení… 3D (Classic) 3D (klasické) Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… + Revolve (Classic)… Rotační těleso (klasické)… Surface Povrch Wireframe Drátový model No Shading Bez stínování diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index af95c4119d..9515a8a066 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -450,6 +450,7 @@ Extrude & Bevel… Ekstrudér og facet… 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… + Revolve (Classic)… Omdrej (klassisk)… Surface Overflade Wireframe Trådmodel No Shading Ingen skyggelægning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 55bc587b64..411f6fc618 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1124,6 +1124,7 @@ font-technology Variable Variabel Extrude & Bevel… Extrudieren und abgeflachte Kante… 3D (Classic) 3D (klassisch) Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… + Revolve (Classic)… Kreiseln (klassisch)… Surface Oberfläche Wireframe Drahtmodell No Shading Keine Schattierung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8732e8d34..698f7d985f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1097,6 +1097,7 @@ font-technology Variable Μεταβλητές Extrude & Bevel… Εξώθηση και λοξότμηση… 3D (Classic) 3D (κλασικό) Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… + Revolve (Classic)… Περιστροφή γύρω από άξονα (κλασικό)… Surface Επιφάνεια Wireframe Συρματόπλεγμα No Shading Χωρίς σκίαση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7eb51ee4f2..e3432f8a83 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1153,6 +1153,7 @@ font-technology Variable Variables Extrude & Bevel… Extrusión y biselado… 3D (Classic) 3D (clásico) Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… + Revolve (Classic)… Revolución (clásico)… Surface Superficie Wireframe Malla No Shading Sin sombreado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433883a44f..6e05cfadb2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -489,6 +489,7 @@ Extrude & Bevel… Pursota ja viistä… 3D (Classic) 3D (perinteinen) Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… + Revolve (Classic)… Pyöräytä (perinteinen)… Surface Pinta Wireframe Rautalanka No Shading Ei varjostusta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6995235edc..89bc73664d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1148,6 +1148,7 @@ font-technology Variable Variables Extrude & Bevel… Extrusion et biseautage… 3D (Classic) 3D (classique) Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… + Revolve (Classic)… Révolution (classique)… Surface Surface Wireframe Fil de fer No Shading Sans ombrage diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 574229b446..96819e6e4c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1745,6 +1745,7 @@ antiAlias Sharp शार्प Extrude & Bevel… एक्सट्रूडेड और बेवेल… 3D (Classic) 3D (क्लासिक) Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… + Revolve (Classic)… रिवॉल्व (क्लासिक)… Surface सतह Wireframe वायरफ़्रेम No Shading कोई शेडिंग नहीं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e4f100338..2a33bbe884 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1444,6 +1444,7 @@ font-technology Variable Változó Extrude & Bevel… Kihúzás és élletörés… 3D (Classic) 3D (klasszikus) Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… + Revolve (Classic)… Forgatás tengely körül (klasszikus)… Surface Felület Wireframe Drótváz No Shading Nincs árnyékolás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a1df85189e..150904d2b5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -450,6 +450,7 @@ Extrude & Bevel… Ekstrusi & bevel… 3D (Classic) 3D (klasik) Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… + Revolve (Classic)… Revolusi (klasik)… Surface Permukaan Wireframe Kerangka kawat No Shading Tanpa pengarsiran diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c1400c91e3..59d39342b2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1154,6 +1154,7 @@ font-technology Variable Variabili Extrude & Bevel… Estrusione e smusso… 3D (Classic) 3D (classico) Extrude & Bevel (Classic)… Estrusione e smusso (classico)… + Revolve (Classic)… Rivoluzione (classico)… Surface Superficie Wireframe Reticolo No Shading Nessuna ombreggiatura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8a9e0cedc9..34bf40c32e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -527,6 +527,7 @@ digits Malayalam マラヤーラム数字 Extrude & Bevel… 押し出しとベベル… 3D (Classic) 3D(クラシック) Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… + Revolve (Classic)… 回転体(クラシック)… Surface 表面 Wireframe ワイヤーフレーム No Shading 陰影なし diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83196486e7..d302a12c2a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -418,6 +418,7 @@ Extrude & Bevel… ამოწევა და წაკვეთა… 3D (Classic) 3D (კლასიკური) Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… + Revolve (Classic)… ბრუნვა (კლასიკური)… Surface ზედაპირი Wireframe მავთულის ჩარჩო No Shading დაჩრდილვის გარეშე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 060c421f28..8e4bf10b61 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -520,6 +520,7 @@ digits Malayalam 말라얄람어 Extrude & Bevel… 돌출 및 경사… 3D (Classic) 3D(클래식) Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… + Revolve (Classic)… 회전체(클래식)… Surface 표면 Wireframe 와이어프레임 No Shading 셰이딩 없음 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e494230f8c..c2cc31699c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -464,6 +464,7 @@ Extrude & Bevel… Extruderen en afschuinen… 3D (Classic) 3D (klassiek) Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… + Revolve (Classic)… Draaien (klassiek)… Surface Oppervlak Wireframe Draadmodel No Shading Geen arcering diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 052d39351a..62c09cf989 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -451,6 +451,7 @@ Extrude & Bevel… Wyciągnięcie i fazowanie… 3D (Classic) 3D (klasyczne) Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… + Revolve (Classic)… Obrót (klasyczny)… Surface Powierzchnia Wireframe Szkielet No Shading Bez cieniowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f78a08845..1f482116ad 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1046,6 +1046,7 @@ antiAlias Sharp Nítido Extrude & Bevel… Extrusão e Bisel… 3D (Classic) 3D (Clássico) Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… + Revolve (Classic)… Revolver (Clássico)… Surface Superfície Wireframe Aramado No Shading Sem Sombreamento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e50ebf68a0..3d39ab221a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1151,6 +1151,7 @@ font-technology Variable Вариативные Extrude & Bevel… Выдавливание и фаска… 3D (Classic) 3D (классические) Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… + Revolve (Classic)… Вращение (классическое)… Surface Поверхность Wireframe Каркас No Shading Без затенения diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 79725b4435..bc1f07f411 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1132,6 +1132,7 @@ font-technology Variable Variabla Extrude & Bevel… Extrudera och fasa… 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… + Revolve (Classic)… Rotera runt axel (klassisk)… Surface Yta Wireframe Trådram No Shading Ingen skuggning diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 05020e1676..4a6d628616 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1124,6 +1124,7 @@ font-technology Variable Değişken Extrude & Bevel… Ekstrüzyon ve Eğim… 3D (Classic) 3D (Klasik) Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… + Revolve (Classic)… Döndür (Klasik)… Surface Yüzey Wireframe Tel Kafes No Shading Gölgeleme Yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13e60355b5..41449c01a1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1148,6 +1148,7 @@ font-technology Variable Варіативні Extrude & Bevel… Видавлювання та фаска… 3D (Classic) 3D (класичні) Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… + Revolve (Classic)… Обертання (класичне)… Surface Поверхня Wireframe Каркас No Shading Без затінення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cb5aaf727..bc517d6f40 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1321,6 +1321,7 @@ font-technology Variable Biến thiên Extrude & Bevel… Kéo dài & vát… 3D (Classic) 3D (cổ điển) Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… + Revolve (Classic)… Xoay tròn (cổ điển)… Surface Bề mặt Wireframe Khung dây No Shading Không tô bóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 286b3a0ca4..5f40262540 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1094,6 +1094,7 @@ antiAlias Sharp 锐利 Extrude & Bevel… 突出与斜角… 3D (Classic) 3D (经典) Extrude & Bevel (Classic)… 突出与斜角 (经典)… + Revolve (Classic)… 回转 (经典)… Surface 表面 Wireframe 线框 No Shading 无底纹 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c819eccd10..83f374090a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1118,6 +1118,7 @@ font-technology Variable 可變 Extrude & Bevel… 突出與斜角… 3D (Classic) 3D (經典) Extrude & Bevel (Classic)… 突出與斜角 (經典)… + Revolve (Classic)… 迴轉 (經典)… Surface 表面 Wireframe 線框 No Shading 無網底 diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3d1577e1be..ae7e4829b0 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -524,6 +524,22 @@ fn classic_extrude_bevel_is_in_the_3d_classic_submenu() { assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap()); } +/// Effect › 3D and Materials › 3D (Classic) › Revolve (Classic)… opens its effect dialog, after +/// Extrude & Bevel (Classic)…, and nowhere else. +#[test] +fn classic_revolve_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicRevolve")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Revolve (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Extrude & Bevel (Classic)…").unwrap() < at("Revolve (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..49bb03781c 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,13 +181,13 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable) is 25–45 h. +angular cut caps, exact visibility, the rest of 3D Classic (Rotate, Map Art), Turntable) is 25–45 h. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 1ce258a111..7a8c001351 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -663,7 +663,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D - Classic (Revolve, Rotate, Map Art), Turntable, textures, map art and shading in 3D export. + Classic (Rotate, Map Art), Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..3e2b9e9358 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3078,8 +3078,8 @@ See [Revolve](revolve.md) for the scope and limitations. replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel -(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and -`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. +(Classic) and Revolve (Classic) have no lighting presets: a target with either at `index` is an error, nothing +changes, and `effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height @@ -3111,7 +3111,7 @@ art", and a loaded file draws the source path, as it does for a solid past its l documentation names the controls and their ranges ([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)). The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps -it as a path. Extrude & Bevel (Classic) has no shadow. See [three-d.md](three-d.md#shadows). +it as a path. Extrude & Bevel (Classic) and Revolve (Classic) have no shadow. See [three-d.md](three-d.md#shadows). ```json {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}} @@ -3187,6 +3187,28 @@ an unknown surface or shading color is refused with an error and the document is {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}} ``` +## Revolve (Classic) + +`effect.apply {effect: "threeD.classicRevolve", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Revolve (Classic)…. It sweeps the profile round a vertical axis as `threeD.revolve` does, at 64 segments +for a full turn, and draws every face, back to front, with the classic surfaces. Parameters: `angle` (degrees, +0..360, default 360), `cap` (true closes the profile's open ends, false leaves the surface hollow), `offset` (points, +0..1000, default 0; the axis's distance from the profile, so a value above 0 makes a ring), `edge` (`left`, the +default, or `right`: the edge of the profile's bounds the axis is at), `rotationX/Y/Z` and `perspectiveAngle` as +above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor` and +`shadingCustom` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group +or layer, whose members turn about one shared axis, or on a fill or stroke. The public documentation describes the +dialog ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)) +and its Angle (0 to 360), Cap, Offset (0 to 1000) and Left Edge or Right Edge settings +([Revolve options](https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=283)); +the defaults are VectorCraft's own. An unknown parameter (`segments` included), a number out of range, a flag that +is not true or false, another `edge`, surface or shading color is refused with an error and the document is not +changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRevolve","item":null,"params":{"angle":270,"offset":10,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork diff --git a/docs/three-d.md b/docs/three-d.md index 1026a9e1be..5fe3d2a89b 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -141,7 +141,7 @@ them all. The members stay editable: moving or editing one reshapes the solids. ## Clipping groups, blends and symbol instances -Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate and Revolve) also applies to a clipping +Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate, Revolve and Revolve (Classic)) also applies to a clipping group, a blend or a symbol instance, alone or inside a group or layer. The help page says that effects apply to objects, groups or layers ([Apply and modify effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html)), that a clipping mask hides everything outside its shape ([Clipping masks](https://helpx.adobe.com/illustrator/using/clipping-masks.html)), @@ -279,7 +279,7 @@ Our reading: command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded file draws the source path. - Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a - path below the faces. Extrude & Bevel (Classic) has no shadow. + path below the faces. Extrude & Bevel (Classic) and Revolve (Classic) have no shadow. ## Parameters (`effect.apply`, `effect.setParams`) @@ -336,8 +336,10 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a circle builds. -- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), - Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. +- **3D (Classic)**: Extrude & Bevel (Classic) and Revolve (Classic) are built, with their surface types and blend + steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws every face back + to front, so it has no Draw Hidden Faces setting. Rotate (Classic), the track cube, map art, Turntable and 3D export + are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). From 5a9c297608fc5456bb682c71cbd441339d4504e7 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:36:34 +0430 Subject: [PATCH 101/126] Compile clipboard failure helper only for its callers --- apps/vectorcraft/src/clipboard.rs | 1 + docs/development.md | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 5756600281..c5ef4d5579 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -651,6 +651,7 @@ mod portable { impl Answer { /// The answer to `request` when it couldn't be read: `why`, or no files. + #[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn failed(request: Request, why: String) -> Self { match request { Request::Text => Self::Text(Err(why)), diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..e7831adf1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. +> **Last updated:** 2026-11-12 · **Change:** minor (clipboard failure helper follows its Linux/BSD and test callers; previously: Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending). > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol. @@ -351,6 +351,10 @@ parameters. Shipped code therefore never panics. Anything that can fail returns ### Enforced by lints +The clipboard timeout failure helper is compiled for Linux/BSD workers and unit tests; production +macOS reads synchronously. Keep helper target gates aligned with their callers instead of disabling +dead-code warnings across the workspace. + `Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with `-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed @@ -508,6 +512,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Compile the clipboard failure helper only for Linux/BSD worker callers and unit tests; keep production macOS free of the unused helper without suppressing workspace dead-code warnings. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | From ff826ed1bd81ce2401182274c59013e8d573ffe4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:48:47 -0800 Subject: [PATCH 102/126] Type: Korean words break at spaces unless Korean Line Break Anywhere is on Korean text broke after every Hangul syllable. A paragraph now has Korean Line Break Anywhere, set from the Paragraph panel menu (with the East Asian options), from the East Asian Composition Settings section of Paragraph Style Options, and with `text.setFormat {koreanLineBreakAnywhere}`. - Off, a word that contains Hangul (the characters between spaces) breaks only at its spaces and after East Asian punctuation, or where the word is wider than the line. Both composers follow it, and the word takes no hyphen. On, a line may break between any two syllables, as before. - New type has it off. Paragraphs and text objects saved before the option read as on, so their lines do not move; the value is saved only when it is off. - Paragraph styles carry it, Reset Panel turns it off, and the .ai writer reports it as lost for Korean paragraphs. The help page: https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/set-line-breaks-korean-text.html The command is described in docs/mcp.md, and docs/target-app-parity.md lists the option. --- crates/doc/src/text.rs | 2 +- crates/doc/src/text/para_style.rs | 20 ++++ crates/doc/src/text/tests.rs | 23 +++++ crates/engine/src/cmd/create.rs | 4 +- crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 43 ++++++++ crates/engine/src/tests/textedit.rs | 1 + crates/eps/src/ai_editing/tests.rs | 28 ++++++ crates/eps/src/ai_editing/writer/texts.rs | 5 + crates/text/src/layout.rs | 2 + crates/text/src/layout/breaking.rs | 14 ++- crates/text/src/layout/flow.rs | 5 + crates/text/src/layout/wrapping_tests.rs | 99 +++++++++++++++++++ crates/text/src/shape.rs | 31 +++++- crates/text/src/tests.rs | 35 +++++++ .../ui-egui/src/dialogs/para_style_options.rs | 41 ++++++-- .../src/dialogs/para_style_options/tests.rs | 23 ++++- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panels/paragraph.rs | 19 +++- crates/ui-egui/src/panels/paragraph/tests.rs | 33 +++++++ docs/mcp.md | 19 ++++ docs/target-app-parity.md | 3 +- 49 files changed, 522 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 05f9c29041..da845ba739 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -52,7 +52,7 @@ pub struct TextObject { /// the object, so the runs (and their styles) stay the text itself. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ruby: Vec<RubySpan>, - #[serde(default)] + #[serde(default = "ParaStyle::omitted")] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f23de1d41f..bb3725072e 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -244,9 +244,21 @@ pub struct ParaStyle { /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with /// kashidas before they widen their word spaces. pub kashida: Kashida, + /// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul + /// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul) + /// breaks only at its spaces and punctuation, or where it is wider than the line. New type and + /// imported text have it off; paragraphs saved before it existed open with it on, the line + /// breaks they were laid out with. + pub korean_line_break_anywhere: bool, } impl ParaStyle { + /// The attributes a text object saved without `para` reads with: the defaults, with Korean + /// Line Break Anywhere on, as a saved paragraph without that field reads. + pub(crate) fn omitted() -> Self { + Self { korean_line_break_anywhere: true, ..Self::default() } + } + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is @@ -330,6 +342,10 @@ struct ParaStyleFile { roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, + /// Written only when off: a paragraph saved without it was laid out breaking between Hangul + /// syllables. + #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")] + korean_line_break_anywhere: bool, } impl From<ParaStyle> for ParaStyleFile { @@ -361,6 +377,7 @@ impl From<ParaStyle> for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = p; Self { @@ -386,6 +403,7 @@ impl From<ParaStyle> for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } @@ -415,6 +433,7 @@ impl From<ParaStyleFile> for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = f; Self { @@ -439,6 +458,7 @@ impl From<ParaStyleFile> for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index aa403b4218..3b3781f87c 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() { assert_eq!(same, d); } +/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph +/// saved before it existed, or a text object saved without paragraph attributes, reads with it on, +/// and both values read back as saved. +#[test] +fn korean_line_break_anywhere_is_saved_only_when_off() { + let off = ParaStyle::default(); + assert!(!off.korean_line_break_anywhere); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["korean_line_break_anywhere"], false); + assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), off); + let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }; + let saved = serde_json::to_value(&on).unwrap(); + assert!(saved.get("korean_line_break_anywhere").is_none()); + assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), on); + let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap(); + assert!(old.korean_line_break_anywhere); + let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap(); + assert_eq!(saved["para"]["korean_line_break_anywhere"], false); + saved.as_object_mut().unwrap().remove("para"); + let old: TextObject = serde_json::from_value(saved).unwrap(); + assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }); +} + /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3c829e8a3e..37473cc59f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> { /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of /// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed -/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own. +/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole +/// (Korean Line Break Anywhere off). Imported text keeps its own. pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle { vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, burasagari: vectorcraft_doc::Burasagari::Standard, + korean_line_break_anywhere: false, ..Default::default() } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b8bdd24d05..83a0655284 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -482,6 +482,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "hyphenate", "hyphenateCapitalized", "romanHangingPunctuation", + "koreanLineBreakAnywhere", ], C, )?; @@ -552,6 +553,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "fontAxes", "burasagari", "romanHangingPunctuation", + "koreanLineBreakAnywhere", "kashida", "composer", ]; @@ -879,6 +881,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = flag("romanHangingPunctuation") { para.roman_hanging = v; } + if let Some(v) = flag("koreanLineBreakAnywhere") { + para.korean_line_break_anywhere = v; + } if let Some(v) = kashida { para.kashida = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index d30e69df68..819da12b3a 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> { ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), + ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), ] { m.insert(k.into(), x); } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 571c9c3469..5545e1be94 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { assert!(text(&s).para_at(1).roman_hanging); } +/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that; +/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a +/// value that is not true or false, a paragraph saved before the option opens with it on, and +/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it. +#[test] +fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none()); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); + assert!(back.korean_line_break_anywhere); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere); + // Saved before the option: on, the line breaks it was laid out with. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(old.korean_line_break_anywhere); + assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err()); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err()); + assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing"); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap(); + assert!(text(&s).para_at(1).korean_line_break_anywhere); + s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9eea9cf456..961e77bd32 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"), ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), + ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index f2ba516b46..9db5617c27 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3785,6 +3785,34 @@ fn an_unstroked_run_with_a_stroke_style_loses_nothing() { } } +/// The text document has no Korean Line Break Anywhere: a paragraph with it on and Hangul in it +/// is reported as left out, and comes back off; with it off, or with no Hangul, nothing is +/// reported. +#[test] +fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [("가나다 라마", true, true), ("가나다 라마", false, false), ("Plain", true, false)] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.korean_line_break_anywhere = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec<bool> = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).korean_line_break_anywhere), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 5ebd73de3a..1d791afadf 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -385,6 +385,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { if p.justify == Justify::Auto && p.direction.is_none() && rtl_letter { return true; } + let hangul = + text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -398,6 +400,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // Width, Standard burasagari) isn't reported: it changes no other script's lines. mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi }, burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari }, + // The text document has no Korean Line Break Anywhere: it reads back off, which + // changes only the lines of a paragraph with Hangul in it. + korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, ..ParaStyle::default() }; *p != held diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 10c44eb73e..d3ffd6774e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -676,6 +676,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -737,6 +738,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index a259ce0881..0f033d4fcb 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -734,8 +734,20 @@ fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { } /// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`, in the -/// language of the word's first letter. +/// language of the word's first letter; never next to a Hangul letter, which takes no hyphen, and +/// nowhere in a Korean word that Korean Line Break Anywhere off keeps whole. fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec<usize> { + if g.get(ws..we).is_some_and(|word| word.iter().any(|h| h.keeps_word)) { + return Vec::new(); + } + let mut points = word_hyphen_breaks(text, g, ws, we, opts); + let hangul = |i: usize| g.get(i).is_some_and(|h| h.inline.is_none() && crate::shape::is_hangul(h.ch)); + points.retain(|&k| !hangul(k.wrapping_sub(1)) && !hangul(k)); + points +} + +/// [`hyphen_breaks`] before the points next to Hangul are left out. +fn word_hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec<usize> { // Strip leading/trailing punctuation (quotes, commas). let (mut a, mut b) = (ws, we); while a < b && !g[a].is_letter() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 0fd3c1275d..d7ac206424 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -10,6 +10,7 @@ use super::{ ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +use crate::shape::keep_korean_words; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, /// with kinsoku on)? @@ -224,6 +225,10 @@ fn prepare_paragraph<'a, 'text>( let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Korean breaks between words, unless Korean Line Break Anywhere lets it break between syllables. + if !para.korean_line_break_anywhere { + keep_korean_words(&mut sg); + } // Japanese composition: consecutive punctuation shares one half-em space. let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e077707a4b..0c8ffb4e2f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -33,6 +33,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -513,3 +514,101 @@ fn composition_counts_the_narrower_line_end() { assert_eq!(lines(38.0, &half, &hyphenated), None); assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); } + +/// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean +/// Line Break Anywhere on). +fn korean(text: &str, anywhere: bool) -> Vec<SGlyph> { + let mut glyphs = full_width(text); + if !anywhere { + crate::shape::keep_korean_words(&mut glyphs); + } + glyphs +} + +/// Korean Line Break Anywhere off: both composers break a Korean word only at its spaces, so a +/// word that doesn't fit moves to the next line whole; on, they break between its syllables. +#[test] +fn korean_words_break_at_spaces_unless_line_break_anywhere() { + use KinsokuShoriType::PushOutOnly; + let text = "가나다 라마바"; + let greedy = + |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::<Vec<_>>(); + // 50 pt holds "가나다 라" and not "가나다 라마". + assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space"); + assert_eq!(greedy(&korean(text, true), 50.0), 5, "on: the line ends after 라"); + assert_eq!(ends(&korean(text, false)), [4]); + assert_eq!(ends(&korean(text, true)), [1, 2, 4, 5, 6]); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let off = korean(text, false); + let cands = candidates(text, &off, None, Kinsoku::Hard); + assert_eq!(compose(&off, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms), Some(vec![(4, false), (7, false)])); + // The first word is measured whole, as the line loop measures it. + assert_eq!(breaking::word_width(&off, 0, &[], Kinsoku::Hard), 30.0); + assert_eq!(breaking::word_width(&korean(text, true), 0, &[], Kinsoku::Hard), 10.0); + // A word wider than the line still breaks, at the syllable that overflows. + assert_eq!(greedy(&korean("가나다라마바", false), 25.0), 2); + // A soft return still ends the line. + let soft = "가나\u{2028}다라"; + assert_eq!( + break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0, + 3 + ); +} + +/// Korean Line Break Anywhere off keeps the Han, kana and full-width letters of a Korean word with +/// it, lets the line break after East Asian punctuation in it (as kinsoku allows), and leaves +/// text without Hangul its own breaks. On, No Break still keeps its characters together. +#[test] +fn korean_words_keep_their_letters_and_break_after_punctuation() { + let ends = |text: &str, anywhere: bool| candidates(text, &korean(text, anywhere), None, Kinsoku::Hard).iter().map(|b| b.end).collect::<Vec<_>>(); + assert_eq!(ends("漢字한국 ab", false), [5], "Han in a Korean word stays with it"); + assert_eq!(ends("日本語 한국어", false), [1, 2, 4], "Japanese keeps its breaks"); + assert_eq!(ends("가나。다라", false), [3], "after the full stop"); + assert_eq!(ends("「가나」다", false), [4], "after the closing bracket, not the opening one"); + assert_eq!(ends("가나AB다", false), Vec::<usize>::new(), "full-width letters stay with it"); + let text = "가나다라"; + let mut glyphs = korean(text, true); + for g in &mut glyphs[1..3] { + g.no_break = true; + } + let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0; + assert_eq!(end, 1, "No Break keeps 나다 together"); +} + +/// Hyphenation adds no hyphen to a Korean word, even where a hyphenation exception marks one: the +/// word that overflows breaks without one, and the every-line composer is offered no hyphenated +/// break in it. +#[test] +fn korean_words_take_no_hyphen() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + let text = "가나다라"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나-다라", || { + let glyphs = korean(text, false); + let line = break_line( + text, + &glyphs, + 0, + 25.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + ); + assert_eq!(line, (2, false)); + assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0)); + }); + // Nor between the Latin letters of a word that also holds Hangul. + let text = "가나foobar"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나foo-bar", || { + let hyphens = + |anywhere: bool| candidates(text, &korean(text, anywhere), Some(&opts), Kinsoku::Hard).iter().filter(|b| b.hyphen > 0.0).count(); + assert_eq!(hyphens(false), 0); + assert_eq!(hyphens(true), 1, "on, the word's Latin letters hyphenate as before"); + }); +} diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 3c56b2c914..54f94dd012 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -74,6 +74,9 @@ pub(crate) struct SGlyph { /// A word of a script written without spaces (Thai, Lao, Khmer, Myanmar) ends after the /// glyph's cluster, so a line may break there ([`mark_word_ends`]). pub word_end: bool, + /// A letter of a Korean word kept whole (Korean Line Break Anywhere off): a CJK character + /// here doesn't let the line break after it ([`keep_korean_words`]). + pub keeps_word: bool, /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, @@ -156,7 +159,7 @@ impl SGlyph { self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}') - || is_cjk(self.ch) + || (is_cjk(self.ch) && !self.keeps_word) } /// A soft (discretionary) hyphen: invisible unless a line breaks after it. pub fn is_soft_hyphen(&self) -> bool { @@ -325,6 +328,30 @@ pub(crate) fn mark_word_ends(text: &str, start: usize, sg: &mut [SGlyph]) { } } +/// Korean Line Break Anywhere off: mark the letters of each Korean word of `sg` (a paragraph's +/// glyphs) as [`SGlyph::keeps_word`], so the word breaks only at its spaces and punctuation. A +/// Korean word is the run of glyphs between spaces and forced line breaks when any of them is +/// Hangul; its Han, kana and full-width letters are kept with it. Punctuation in it still lets +/// the line break after it, and runs without Hangul keep their own breaks. +pub(crate) fn keep_korean_words(sg: &mut [SGlyph]) { + let mut start = 0; + while start < sg.len() { + let len = sg[start..].iter().position(|g| g.is_space() || g.ch == '\u{2028}').unwrap_or(sg.len() - start); + let word = &mut sg[start..start + len]; + if word.iter().any(|g| g.inline.is_none() && is_hangul(g.ch)) { + for g in word.iter_mut().filter(|g| g.inline.is_none()) { + g.keeps_word = g.ch.is_alphanumeric(); + } + } + start += len + 1; + } +} + +/// Is `c` Hangul: a syllable, a jamo or a compatibility jamo (full or half width)? +pub(crate) fn is_hangul(c: char) -> bool { + c.script() == Script::Hangul +} + pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } @@ -1017,6 +1044,7 @@ fn inline_glyph( level, inline: Some(InlineBox { art: drawn, xf }), word_end: false, + keeps_word: false, no_break: st.no_break, vertical_roman: false, cursive: false, @@ -1446,6 +1474,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe level: *level, inline: None, word_end: false, + keeps_word: false, no_break: gst.no_break, vertical_roman: gst.standard_vertical_roman, cursive, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8934090d5a..cf50150d93 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2515,6 +2515,41 @@ fn thai_loanwords_stay_whole() { assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}"); } +/// Korean Line Break Anywhere (a paragraph attribute): off, a Korean word that does not fit moves +/// to the next line whole, with either composer and in vertical type; on, the line breaks between +/// its syllables. Each paragraph follows its own setting. +#[test] +fn korean_words_stay_whole_unless_line_break_anywhere_is_on() { + let text = "가나다 라마바"; + let first = "가나다 ".len(); + let measure = |vertical: bool| { + let mut t = point("가나다 라", style(20.0)); + t.vertical = vertical; + layout(db(), &t).glyphs.iter().map(|g| g.advance).sum::<f64>() + }; + let ends = |t: &TextObject| layout(db(), t).lines.iter().map(|l| l.end).collect::<Vec<_>>(); + for vertical in [false, true] { + let fits = measure(vertical); + let frame = if vertical { Rect::new(0.0, 0.0, 400.0, fits + 1.0) } else { Rect::new(0.0, 0.0, fits + 1.0, 400.0) }; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(text, style(20.0), frame, Justify::Left); + (t.vertical, t.para.composer) = (vertical, composer); + assert_eq!(ends(&t), [first, text.len()], "{composer:?} vertical {vertical}: off"); + t.para.korean_line_break_anywhere = true; + let on = ends(&t); + assert!(on.iter().any(|&e| e != first && e != text.len()), "{composer:?} vertical {vertical}: on {on:?}"); + } + } + // Per paragraph: the first breaks between syllables, the second between words. + let fits = measure(false); + let mut t = area(&format!("{text}\n{text}"), style(20.0), Rect::new(0.0, 0.0, fits + 1.0, 400.0), Justify::Left); + t.para.composer = Composer::SingleLine; + let anywhere = vectorcraft_doc::ParaStyle { korean_line_break_anywhere: true, ..t.para.clone() }; + t.set_paragraph_styles(vec![anywhere, t.para.clone()]); + let second = text.len() + 1; + assert_eq!(ends(&t), ["가나다 라".len(), text.len(), second + first, second + text.len()]); +} + /// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps /// at the space without the mark. #[test] diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index ffd0a23682..10f723335b 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -1,6 +1,7 @@ //! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic //! Paragraph Formats (alignment, indents and spacing), Composition (composer and Roman Hanging -//! Punctuation), Hyphenation and Justification. +//! Punctuation), Hyphenation, Justification and East Asian Composition Settings (Korean Line Break +//! Anywhere). //! //! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves //! its attribute out of the style. Distances are shown and typed in the General unit and kept in @@ -21,7 +22,7 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 5] = ["general", "basic", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "composition", "hyphenation", "justification", "eastAsian"]; /// The distance fields of Basic Paragraph Formats, which are also the attributes they set. const DISTANCES: [&str; 5] = ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"]; /// The range of the indents, the space before and after, and the Hyphenation Zone (pt), as @@ -98,6 +99,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "korean_line_break_anywhere", "section", ] }, @@ -154,7 +156,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>, unit: Unit) -> Map<S fields.insert(key.into(), distance_field(attrs.get(key), unit)); } fields.insert("composer".into(), attrs.get("composer").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); - for key in ["roman_hanging", "hyphenate"] { + for key in ["roman_hanging", "hyphenate", "korean_line_break_anywhere"] { fields.insert(key.into(), attrs.get(key).filter(|v| v.is_boolean()).cloned().unwrap_or(Value::Null)); } let hyphenation = attrs.get("hyphenation"); @@ -292,6 +294,9 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> { let same = JUSTIFICATION_FIELDS.iter().all(|key| unchanged(key, "__resetJustification")); let value = if same { None } else { justification(d, &original, &original_fields)? }; keep_or(&mut attrs, "justification", same, value); + + let same = unchanged("korean_line_break_anywhere", "__resetEastAsian"); + keep_or(&mut attrs, "korean_line_break_anywhere", same, switch("korean_line_break_anywhere")); Ok(attrs) } @@ -416,9 +421,15 @@ fn reset_justification(d: &mut Dialog) { } } +/// Reset Panel in East Asian Composition Settings: Korean Line Break Anywhere becomes unset. +fn reset_east_asian(d: &mut Dialog) { + d.fields.insert("__resetEastAsian".into(), json!(true)); + d.fields.insert("korean_line_break_anywhere".into(), Value::Null); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "composition", "hyphenation", "justification", "eastAsian"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. @@ -428,6 +439,7 @@ pub(crate) fn reset_section(d: &mut Dialog) { "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), + "eastAsian" => reset_east_asian(d), _ => {} } } @@ -449,9 +461,15 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result<Value, String> { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - for (id, label) in - SECTIONS.into_iter().zip([tl!("General"), tl!("Basic Paragraph Formats"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]) - { + let labels = [ + tl!("General"), + tl!("Basic Paragraph Formats"), + tl!("Composition"), + tl!("Hyphenation"), + tl!("Justification"), + tl!("East Asian Composition Settings"), + ]; + for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); } @@ -597,6 +615,14 @@ pub(crate) fn justification_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// East Asian Composition Settings: Korean Composition Settings › Korean Line Break Anywhere. +pub(crate) fn east_asian_body(ui: &mut egui::Ui, d: &mut Dialog) { + ui.strong(tl!("Korean Composition Settings")); + egui::Grid::new("paragraph-style-east-asian").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + switch(ui, d, "korean_line_break_anywhere", tl!("Korean Line Break Anywhere"), true); + }); +} + /// The heading of a spacing column, for the accessible name of its field. fn column_name(column: &str) -> &'static str { match column { @@ -627,6 +653,7 @@ pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), + "eastAsian" => east_asian_body(ui, d), _ => general_body(ui, d), } } diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 42fa0bd491..7aa6552cda 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -157,7 +157,8 @@ fn invalid_values_keep_the_dialog_open() { fn reset_panel_clears_only_its_section() { let mut app = app(); let all = json!({"justify": "Center", "space_after": 6.0, "composer": "singleLine", "roman_hanging": true, "hyphenate": true, - "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}]}); + "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}], + "korean_line_break_anywhere": true}); app.run("paraStyle.new", json!({"name": "Body", "attrs": all})).unwrap(); let stored = def(&app, "Body"); for (section, cleared) in [ @@ -165,6 +166,7 @@ fn reset_panel_clears_only_its_section() { ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), + ("eastAsian", &["korean_line_break_anywhere"]), ] { app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); assert!(app.run("ui.paraStyleOptions.resetPanel", json!({})).is_err(), "General has no Reset Panel"); @@ -257,6 +259,24 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// East Asian Composition Settings › Korean Line Break Anywhere sets the style's attribute, which +/// its paragraphs take; unset, the style leaves it out and the paragraphs keep their own. +#[test] +fn korean_line_break_anywhere_is_set_by_the_east_asian_section() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "가나 다라"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + assert!(!paras(&app, id)[0].korean_line_break_anywhere); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["korean_line_break_anywhere"].is_null()); + set(&mut app, "section", json!("eastAsian")); + set(&mut app, "korean_line_break_anywhere", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("korean_line_break_anywhere"), Some(&json!(true))); + assert!(paras(&app, id)[0].korean_line_break_anywhere, "the style's paragraphs take it"); +} + /// The section list, Reset Panel and both buttons are drawn in every section, and the Hyphenation /// options are drawn whether or not Hyphenation is on. #[test] @@ -272,6 +292,7 @@ fn every_section_draws_its_fields() { ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), + ("eastAsian", "Korean Line Break Anywhere"), ] { set(&mut app, "section", json!(section)); let mut texts = String::new(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6b770ec1bc..63e9c7939d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2825,6 +2825,9 @@ digits Malayalam Малаялам Rise… Издигане… Roman Baseline Латинска основна линия Roman Hanging Punctuation Латинска висяща пунктуация + Korean Line Break Anywhere Пренасяне на корейски текст навсякъде + East Asian Composition Settings Настройки за източноазиатска композиция + Korean Composition Settings Настройки за корейска композиция Rotate Завъртане Rotate 90° CW Завъртане на 90° по часовниковата стрелка Rotate Tool Инструмент „Завъртане“ diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f6029f08ef..f2915b0823 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1975,6 +1975,9 @@ digits Malayalam Malajalam Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo Roman Hanging Punctuation Latinična viseća interpunkcija + Korean Line Break Anywhere Prelom korejskog reda bilo gdje + East Asian Composition Settings Postavke istočnoazijske kompozicije + Korean Composition Settings Postavke korejske kompozicije Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa. Single-line Composer Slagač jednog reda Toggle Line Composer Uključi/isključi Line Composer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..f04f27658e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3615,6 +3615,9 @@ digits Malayalam Malaiàlam Roman Romà Roman Baseline Línia de base romana Roman Hanging Punctuation Puntuació penjant romana + Korean Line Break Anywhere Salt de línia coreà a qualsevol lloc + East Asian Composition Settings Configuració de composició de l’Àsia oriental + Korean Composition Settings Configuració de composició coreana Rotate Gira Rotate 90° CW Gira 90° en sentit horari Rotate Tool Eina de gir diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..59747e6907 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2002,6 +2002,9 @@ digits Malayalam Malajálamské Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva Roman Hanging Punctuation Visící latinková interpunkce + Korean Line Break Anywhere Zalomení korejského řádku kdekoli + East Asian Composition Settings Nastavení východoasijské sazby + Korean Composition Settings Nastavení korejské sazby Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce. Single-line Composer Sazba jednoho řádku Toggle Line Composer Přepnout sazbu řádků diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00b46b0f22..279d260fdd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1808,6 +1808,9 @@ digits Malayalam Malayalam Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre Roman Hanging Punctuation Romersk hængende tegnsætning + Korean Line Break Anywhere Koreansk linjeskift hvor som helst + East Asian Composition Settings Indstillinger for østasiatisk komposition + Korean Composition Settings Indstillinger for koreansk komposition Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. Single-line Composer Enlinjesætter Toggle Line Composer Skift linjesætter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..3b43a9d60c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2867,6 +2867,9 @@ digits Malayalam Malayalam Rise… Ansteigend… Roman Baseline Lateinische Grundlinie Roman Hanging Punctuation Hängende Interpunktion (lateinisch) + Korean Line Break Anywhere Koreanischer Zeilenumbruch überall + East Asian Composition Settings Einstellungen für ostasiatischen Satz + Korean Composition Settings Einstellungen für koreanischen Satz Rotate Drehen Rotate 90° CW Um 90° im Uhrzeigersinn drehen Rotate Tool Drehen-Werkzeug diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..ba6d8f471c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1953,6 +1953,9 @@ font-technology Variable Μεταβλητές Right: Δεξιά: Rise… Ανύψωση… Roman Hanging Punctuation Κρεμαστή στίξη όρθιας γραφής + Korean Line Break Anywhere Αλλαγή γραμμής κορεατικών οπουδήποτε + East Asian Composition Settings Ρυθμίσεις σύνθεσης ανατολικής Ασίας + Korean Composition Settings Ρυθμίσεις κορεατικής σύνθεσης Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..354b956eb4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2900,6 +2900,9 @@ digits Malayalam Malayálam Rise… Ascenso… Roman Baseline Línea de base latina Roman Hanging Punctuation Puntuación colgante romana + Korean Line Break Anywhere Salto de línea coreano en cualquier lugar + East Asian Composition Settings Ajustes de composición de Asia oriental + Korean Composition Settings Ajustes de composición coreana Rotate Rotar Rotate 90° CW Rotar 90° en sentido horario Rotate Tool Herramienta Rotar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..17e3688e0c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1963,6 +1963,9 @@ digits Malayalam Malajalam Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle Roman Hanging Punctuation Latinalaiset riippuvat välimerkit + Korean Line Break Anywhere Korean rivinvaihto missä tahansa + East Asian Composition Settings Itäaasialaisen ladonnan asetukset + Korean Composition Settings Korean ladonnan asetukset Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata. Single-line Composer Yhden rivin asettelu Toggle Line Composer Vaihda rivien asettelu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..a7ff61d2c7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2892,6 +2892,9 @@ digits Malayalam Malayalam Rise… Élévation… Roman Baseline Ligne de base romaine Roman Hanging Punctuation Ponctuation romaine hors justification + Korean Line Break Anywhere Saut de ligne coréen n’importe où + East Asian Composition Settings Paramètres de composition d’Asie orientale + Korean Composition Settings Paramètres de composition coréenne Rotate Rotation Rotate 90° CW Rotation de 90° horaire Rotate Tool Outil Rotation diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..51c86651a0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3941,6 +3941,9 @@ antiAlias Sharp शार्प Roman रोमन Roman Baseline रोमन बेसलाइन Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न + Korean Line Break Anywhere कोरियाई लाइन ब्रेक कहीं भी + East Asian Composition Settings पूर्वी एशियाई संरचना सेटिंग्स + Korean Composition Settings कोरियाई संरचना सेटिंग्स Rotate घुमाएँ Rotate 90° CW 90° सीडब्ल्यू Rotate Tool रोटेट टूल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..6c7ec9942a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3475,6 +3475,9 @@ mark-style Japanese Japán Roman Latin Roman Baseline Latin alapvonal Roman Hanging Punctuation Latin függő írásjelek + Korean Line Break Anywhere Koreai sortörés bárhol + East Asian Composition Settings Kelet-ázsiai szedési beállítások + Korean Composition Settings Koreai szedési beállítások Rotate Elforgatás Rotate 90° CW Forgatás 90°-kal jobbra Rotate Tool Elforgatás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9925f7b0d8..523487963f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1808,6 +1808,9 @@ digits Malayalam Malayalam Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri Roman Hanging Punctuation Tanda baca gantung Romawi + Korean Line Break Anywhere Putus baris Korea di mana saja + East Asian Composition Settings Pengaturan komposisi Asia Timur + Korean Composition Settings Pengaturan komposisi Korea Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. Single-line Composer Komposer satu baris Toggle Line Composer Alihkan komposer baris diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..146e886342 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2943,6 +2943,9 @@ digits Malayalam Malayalam Rise… Ascesa… Roman Baseline Linea di base romana Roman Hanging Punctuation Punteggiatura sporgente romana + Korean Line Break Anywhere Interruzione di riga coreana ovunque + East Asian Composition Settings Impostazioni di composizione dell’Asia orientale + Korean Composition Settings Impostazioni di composizione coreana Rotate Ruota Rotate 90° CW Ruota di 90° in senso orario Rotate Tool Strumento Ruota diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..d12a60f037 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2929,6 +2929,9 @@ mark-style Japanese 日本式 Right Plane 右面 Right: 右: Roman Hanging Punctuation 欧文ぶら下がり + Korean Line Break Anywhere 韓国語の任意位置での改行 + East Asian Composition Settings 東アジア言語の組版設定 + Korean Composition Settings 韓国語の組版設定 Rotate 回転 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..c538d0eaef 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1734,6 +1734,9 @@ digits Malayalam მალაიალამური Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Korean Line Break Anywhere კორეული სტრიქონის გადატანა ნებისმიერ ადგილას + East Asian Composition Settings აღმოსავლეთ აზიური აწყობის პარამეტრები + Korean Composition Settings კორეული აწყობის პარამეტრები Select a text object to edit its paragraph attributes. აბზაცის ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. Single-line Composer სტრიქონის მიხედვით აწყობა Toggle Line Composer აწყობის რეჟიმის გადართვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..8e3062266d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2918,6 +2918,9 @@ mark-style Japanese 일본식 Right Plane 오른쪽 평면 Right: 오른쪽: Roman Hanging Punctuation 로마자 걸림 문장 부호 + Korean Line Break Anywhere 한글 어디서나 줄 바꿈 + East Asian Composition Settings 동아시아 조판 설정 + Korean Composition Settings 한글 조판 설정 Rotate 회전 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20ea6ac94d..a24bd2c129 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1822,6 +1822,9 @@ digits Malayalam Malayalam Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links Roman Hanging Punctuation Romeinse hangende leestekens + Korean Line Break Anywhere Koreaans regeleinde overal + East Asian Composition Settings Instellingen voor Oost-Aziatische zetting + Korean Composition Settings Instellingen voor Koreaanse zetting Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. Single-line Composer Compositie per regel Toggle Line Composer Compositiemethode wisselen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..9c87dc8c32 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1920,6 +1920,9 @@ digits Malayalam Malajalam Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej Roman Hanging Punctuation Wysunięta interpunkcja łacińska + Korean Line Break Anywhere Koreański podział wiersza w dowolnym miejscu + East Asian Composition Settings Ustawienia składu wschodnioazjatyckiego + Korean Composition Settings Ustawienia składu koreańskiego Select a text object to edit its paragraph attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty akapitu. Single-line Composer Kompozytor jednowierszowy Toggle Line Composer Przełącz kompozytor wierszy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..5679a4fd4e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2430,6 +2430,9 @@ digits Malayalam Malaiala Rise… Rise… Roman Baseline Linha de Base Romana Roman Hanging Punctuation Pontuação Pendurada Romana + Korean Line Break Anywhere Quebra de Linha Coreana em Qualquer Lugar + East Asian Composition Settings Configurações de Composição do Leste Asiático + Korean Composition Settings Configurações de Composição Coreana Rotate Girar Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..608c7235c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2930,6 +2930,9 @@ digits Malayalam Малаялам Rise… Подъём… Roman Baseline Римская базовая линия Roman Hanging Punctuation Римская висячая пунктуация + Korean Line Break Anywhere Перенос корейского текста в любом месте + East Asian Composition Settings Параметры восточноазиатского набора + Korean Composition Settings Параметры корейского набора Rotate Повернуть Rotate 90° CW Повернуть на 90° по часовой стрелке Rotate Tool Инструмент «Поворот» diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..1454a8cea1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2836,6 +2836,9 @@ digits Malayalam Malayalam Rise… Stigning… Roman Baseline Baslinje (roman) Roman Hanging Punctuation Hängande interpunktion (roman) + Korean Line Break Anywhere Koreansk radbrytning var som helst + East Asian Composition Settings Inställningar för östasiatisk sättning + Korean Composition Settings Inställningar för koreansk sättning Rotate Rotera Rotate 90° CW Rotera 90° medurs Rotate Tool Rotationsverktyg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..a664d93b0b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2703,6 +2703,9 @@ digits Malayalam Malayalam Rise… Yüksel… Roman Baseline Roma Taban Çizgisi Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Korean Line Break Anywhere Korece Satırı Her Yerde Kes + East Asian Composition Settings Doğu Asya Dizgi Ayarları + Korean Composition Settings Korece Dizgi Ayarları Rotate Döndür Rotate 90° CW 90° CW döndür Rotate Tool Döndürme Aracı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..f6ba474387 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2935,6 +2935,9 @@ digits Malayalam Малаялам Rise… Підйом… Roman Baseline Римська базова лінія Roman Hanging Punctuation Римська висяча пунктуація + Korean Line Break Anywhere Перенесення корейського тексту будь-де + East Asian Composition Settings Параметри східноазійського набору + Korean Composition Settings Параметри корейського набору Rotate Повернути Rotate 90° CW Повернути на 90° за годинниковою стрілкою Rotate Tool Інструмент «Поворот» diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..2d947b7e41 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3239,6 +3239,9 @@ mark-style Japanese Kiểu Nhật Rise… Nâng lên… Roman Baseline Đường cơ sở Roman Roman Hanging Punctuation Dấu câu treo Roman + Korean Line Break Anywhere Ngắt dòng tiếng Hàn ở bất kỳ đâu + East Asian Composition Settings Cài đặt dàn trang Đông Á + Korean Composition Settings Cài đặt dàn trang tiếng Hàn Rotate Xoay Rotate 90° CW Xoay 90° theo chiều kim đồng hồ Rotate Tool Công cụ Xoay diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..26a1b98ddb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2799,6 +2799,9 @@ digits Malayalam 马拉雅拉姆数字 Rise… 上升… Roman Baseline 罗马基线 Roman Hanging Punctuation 西文悬挂标点 + Korean Line Break Anywhere 韩文任意位置换行 + East Asian Composition Settings 东亚排版设置 + Korean Composition Settings 韩文排版设置 Rotate 旋转 Rotate 90° CW 顺时针旋转 90° Rotate Tool 旋转工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..04b30f48d5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3906,6 +3906,9 @@ digits Malayalam 馬來亞拉姆數字 Rise… 上升… Roman Baseline 羅馬基線 Roman Hanging Punctuation 羅馬懸掛標點 + Korean Line Break Anywhere 韓文任意位置換行 + East Asian Composition Settings 東亞排版設定 + Korean Composition Settings 韓文排版設定 Rotate 旋轉 Rotate 90° CW 順時針旋轉 90° Rotate Tool 旋轉工具 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 32d15d715f..cabca387ae 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Korean lines break between any two syllables, not only between words. + let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { + toggle_korean_line_break_anywhere(app); + } } ui.separator(); let composer = shared_composer(app); @@ -899,12 +904,20 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph +/// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for +/// all of them otherwise, in one undo step. +fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + format(app, json!({"koreanLineBreakAnywhere": !on})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi /// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside -/// an undo group that is already open, as part of its step). +/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -924,7 +937,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 347975f680..517ef1eeab 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -133,6 +133,39 @@ fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); } +/// Korean Line Break Anywhere: new type has it off; the menu item turns it on for the paragraphs +/// the Type tool's selection touches in one undo step, is unchecked while the selected paragraphs +/// differ, turns it on for all of them then and off again once they all have it. Reset Panel turns +/// it off. +#[test] +fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("가나\n다라\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).korean_line_break_anywhere, text.para_at(1).korean_line_break_anywhere) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.korean_line_break_anywhere), None, "mixed: unchecked"); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..4987959351 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7157,6 +7157,25 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Korean Line Break Anywhere + +`text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the +East Asian options) turns it on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / +`end` range touches). Off, a Korean word (the characters between two spaces, when any of them is Hangul, Han and kana +included) breaks only at its spaces, after East Asian punctuation in it, and at forced line breaks, so a word that does +not fit moves to the next line whole; a word wider than the line still breaks where it overflows. On, a line may break +between any two Hangul syllables. Both composers and vertical type follow it, No Break and the Kinsoku Set still apply, +and hyphenation never adds a hyphen to Hangul. Text without Hangul keeps its own breaks. New type and imported text have +it off; paragraphs saved before the option open with it on, the breaks they were laid out with. It is saved with the +paragraph (`korean_line_break_anywhere` in `para`) only when off, paragraph styles carry it (Paragraph Style Options › +East Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a Korean paragraph with it on as left out. The +help page: https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/set-line-breaks-korean-text.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"koreanLineBreakAnywhere":true}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..19939e6e06 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1193,7 +1193,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. From 8b4fdabb116366fa883919b043b2b3a222f40fe7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:12:33 -0800 Subject: [PATCH 103/126] Tests: the id and ids comparisons leave plug-in effects out of effect.list's catalog effect.list's catalog lists the plug-ins installed in the process-wide registry, which the plug-in tests change while the id and ids comparisons run in the same test binary. Both comparisons, id against ids of one and ids against the selection, now take the plug-in effects (plugin.<id>) out of effect.list's catalog before comparing. The built-in catalog, applied, activeItem, errors and the document are still compared. --- .../engine/src/tests/ids_without_selection.rs | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 1e13c468c3..24a1635c30 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -94,10 +94,49 @@ fn run_fresh( params: &serde_json::Value, ) -> (std::result::Result<serde_json::Value, String>, Option<std::sync::Arc<vectorcraft_doc::Document>>) { let (mut s, ..) = doc_with_type(); - let r = s.execute(cmd, params).map_err(|e| e.to_string()); + let r = s.execute(cmd, params).map(|v| without_plugin_effects(cmd, v)).map_err(|e| e.to_string()); (r, s.doc().ok().map(|d| d.doc.clone())) } +/// `cmd`'s result with the plug-in effects (`plugin.<id>`) taken out of `effect.list`'s catalog. +/// The plug-in tests install and remove plug-ins in the process-wide registry while these +/// comparisons run, so two runs of `effect.list` can list different plug-ins. +fn without_plugin_effects(cmd: &str, mut r: serde_json::Value) -> serde_json::Value { + if cmd == "effect.list" + && let Some(catalog) = r.get_mut("catalog").and_then(serde_json::Value::as_array_mut) + { + catalog.retain(|e| !e["id"].as_str().is_some_and(|id| id.starts_with(vectorcraft_plugins::PREFIX))); + } + r +} + +/// Whether two results of `cmd` are the same, apart from the plug-in effects in `effect.list`'s +/// catalog ([`without_plugin_effects`]). +fn same_without_plugin_effects(cmd: &str, x: &serde_json::Value, y: &serde_json::Value) -> bool { + if cmd == "effect.list" { without_plugin_effects(cmd, x.clone()) == without_plugin_effects(cmd, y.clone()) } else { x == y } +} + +/// Two `effect.list` results that differ only in a plug-in effect in the catalog compare the same; +/// a difference in a built-in effect, in `applied` or in `activeItem`, or in another command's +/// result, does not. +#[test] +fn plugin_effects_are_left_out_of_the_effect_list_comparison() { + let list = |catalog: &[&str], applied: serde_json::Value, active: serde_json::Value| { + let catalog: Vec<_> = catalog.iter().map(|id| json!({"id": id, "label": id})).collect(); + json!({"catalog": catalog, "applied": applied, "activeItem": active}) + }; + let base = list(&["stylize.dropShadow"], json!([]), json!(null)); + let with_plugin = list(&["stylize.dropShadow", "plugin.test.glow"], json!([]), json!(null)); + assert!(same_without_plugin_effects("effect.list", &base, &with_plugin)); + assert!(same_without_plugin_effects("effect.list", &with_plugin, &base)); + assert_eq!(without_plugin_effects("effect.list", with_plugin.clone()), base); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&[], json!([]), json!(null))), "a built-in effect"); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([{"id": 3}]), json!(null))), "applied"); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([]), json!(0))), "activeItem"); + assert!(!same_without_plugin_effects("object.transform", &base, &with_plugin), "another command"); + assert_eq!(without_plugin_effects("object.transform", with_plugin.clone()), with_plugin); +} + /// Every command whose params description names both `id` and `ids` (`{ids?|id?}`, `{id?, ids?: /// … (instead of id)}`), with nothing selected: `id` does what `ids` of that one object does (the /// same result or the same error, and the same document), for objects of the document and for an @@ -265,7 +304,7 @@ fn ids_and_selection(cmd: &str, picks: &[usize]) -> std::result::Result<bool, St with_ids["ids"] = json!(list); let (a, b) = (given.execute(cmd, &with_ids), selected.execute(cmd, ¶ms)); let same_result = match (&a, &b) { - (Ok(x), Ok(y)) => x == y, + (Ok(x), Ok(y)) => same_without_plugin_effects(cmd, x, y), (Err(x), Err(y)) => x.to_string() == y.to_string(), _ => false, }; From fc8a7e46c6d4a1ef02e132b6e3941b2b545dc756 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:19:16 -0800 Subject: [PATCH 104/126] =?UTF-8?q?Swatch=20libraries:=20Gradients=20?= =?UTF-8?q?=E2=80=BA=20Holographic=20adds=20iridescent=20foil=20and=20glow?= =?UTF-8?q?=20gradients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Gradients gains Holographic, after Organic Fades: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), all mixed with the Perceptual method. They also list in the Swatches panel's library menu, swatch.library.list/get/add and the Gradient panel's Presets. The names, colors and stops are VectorCraft's own. A saved preset that already uses one of the new names is renamed with the first free number when the preferences load. Docs: docs/target-app-parity.md Swatches line, ROADMAP. --- ROADMAP.md | 1 + crates/color/src/libraries.rs | 113 ++++++++++++++++++++++++- crates/engine/src/tests/gradpresets.rs | 47 +++++++++- crates/engine/src/tests/swatchlib.rs | 38 +++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/widgets.rs | 17 ++++ docs/target-app-parity.md | 2 +- 33 files changed, 242 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3e8cfb9d34..b28d4dbef8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. +- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index 5b5fd3653a..5fc952a061 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -73,6 +73,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[ lib!("sky-gradients", "Sky Gradients", sky_gradients), lib!("neutral-gradients", "Neutral Gradients", neutral_gradients), lib!("organic-fades", "Organic Fades", organic_fades), + lib!("holographic", "Holographic", holographic), lib!("duotone-gradients", "Duotone Gradients", duotone_gradients), lib!("radial-glows", "Radial Glows", radial_glows), ]; @@ -531,6 +532,62 @@ fn organic_fades() -> (Vec<Swatch>, Vec<SwatchGroup>) { (s, vec![]) } +/// The stops of a [`holographic`] look: offset, opacity and midpoint toward the next stop. +type Bands = [(f32, f32, f32)]; + +/// Foil: six opaque linear bands, a narrow bright one among them, with off-center midpoints. +const FOIL: &Bands = &[(0.0, 1.0, 0.37), (0.13, 1.0, 0.64), (0.34, 1.0, 0.28), (0.48, 1.0, 0.68), (0.71, 1.0, 0.42), (1.0, 1.0, 0.5)]; + +/// Glow: five radial rings from a bright center out to a clear edge. +const GLOW: &Bands = &[(0.0, 0.95, 0.4), (0.12, 1.0, 0.58), (0.31, 0.72, 0.34), (0.58, 0.3, 0.62), (1.0, 0.0, 0.5)]; + +/// A [`holographic`] look: name, kind, stops and the OKLCH color of each stop in order. +type Look = (&'static str, GradientKind, &'static Bands, Anchors); + +/// Iridescent looks mixed perceptually: linear foils of narrow pastel and pearl bands, and radial +/// glows that fade out to a clear edge. Each color is one stop of [`FOIL`] or [`GLOW`] in order. +fn holographic() -> (Vec<Swatch>, Vec<SwatchGroup>) { + use GradientKind::{Linear, Radial}; + // Pale pearl, pastel and deep-violet OKLCH `(l, c, h°)` anchors. + const PEARL: (f64, f64, f64) = (0.97, 0.01, 90.0); + const SILVER: (f64, f64, f64) = (0.86, 0.015, 255.0); + const BLUSH: (f64, f64, f64) = (0.82, 0.055, 340.0); + const VIOLET: (f64, f64, f64) = (0.72, 0.1, 310.0); + const BLUE: (f64, f64, f64) = (0.73, 0.09, 255.0); + const CYAN: (f64, f64, f64) = (0.79, 0.09, 210.0); + const MINT: (f64, f64, f64) = (0.82, 0.08, 165.0); + const LIME: (f64, f64, f64) = (0.86, 0.085, 125.0); + const GOLD: (f64, f64, f64) = (0.89, 0.08, 85.0); + const AMBER: (f64, f64, f64) = (0.82, 0.1, 55.0); + const CORAL: (f64, f64, f64) = (0.78, 0.09, 25.0); + const DEEP_VIOLET: (f64, f64, f64) = (0.42, 0.04, 275.0); + let looks: [Look; 14] = [ + ("Prism Veil", Linear, FOIL, &[SILVER, BLUSH, PEARL, CYAN, VIOLET, SILVER]), + ("Opal Ribbon", Linear, FOIL, &[PEARL, MINT, BLUSH, PEARL, BLUE, SILVER]), + ("Pearl Flash", Linear, FOIL, &[SILVER, VIOLET, PEARL, GOLD, CYAN, PEARL]), + ("Aurora Foil", Linear, FOIL, &[BLUE, MINT, PEARL, BLUSH, CYAN, SILVER]), + ("Iris Sheen", Linear, FOIL, &[BLUSH, VIOLET, PEARL, CYAN, MINT, SILVER]), + ("Frost Spectrum", Linear, FOIL, &[CYAN, BLUE, PEARL, MINT, BLUSH, SILVER]), + ("Silver Tide", Linear, FOIL, &[SILVER, CYAN, PEARL, VIOLET, BLUE, SILVER]), + ("Rose Quartz Gleam", Radial, GLOW, &[PEARL, BLUSH, CORAL, VIOLET, BLUSH]), + ("Moonstone Shift", Radial, GLOW, &[PEARL, CYAN, MINT, BLUE, SILVER]), + ("Violet Edge", Radial, GLOW, &[PEARL, VIOLET, BLUSH, BLUE, DEEP_VIOLET]), + ("Mint Halo", Radial, GLOW, &[PEARL, MINT, CYAN, LIME, MINT]), + ("Amber Corona", Radial, GLOW, &[PEARL, GOLD, AMBER, CORAL, AMBER]), + ("Polar Glint", Radial, GLOW, &[PEARL, CYAN, BLUE, VIOLET, BLUE]), + ("Pastel Orbit", Radial, GLOW, &[PEARL, BLUSH, MINT, CYAN, VIOLET]), + ]; + let s = looks + .iter() + .map(|&(name, kind, bands, colors)| { + let stops: Vec<(f32, Color, f32, f32)> = + bands.iter().zip(colors).map(|(&(at, opacity, mid), &(l, c, h))| (at, oklch(l, c, h), opacity, mid)).collect(); + gradient_with(name, kind, GradientMethod::Perceptual, &stops) + }) + .collect(); + (s, vec![]) +} + /// The sequential scales of [`perceptual`]: name and anchors of rising lightness. const SEQUENTIAL: [(&str, Anchors); 5] = [ ("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]), @@ -708,9 +765,63 @@ mod tests { } } + #[test] + fn holographic_holds_seven_opaque_foils_and_seven_glows_to_a_clear_edge() { + let lib = builtin_library("holographic").unwrap(); + let order: Vec<&str> = GRADIENT_LIBRARIES.iter().map(|b| b.name).collect(); + assert_eq!(order.iter().position(|n| *n == "Holographic"), Some(4), "after Organic Fades: {order:?}"); + assert!(lib.groups.is_empty()); + assert_eq!(lib.len(), 14); + // No name another built-in gradient has, in any case. + let mut others = std::collections::HashSet::new(); + for other in builtins().filter(|b| b.id != "holographic").map(|b| builtin_library(b.id).unwrap()) { + others.extend(other.iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| s.name.to_lowercase())); + } + assert!(lib.iter().all(|s| !others.contains(&s.name.to_lowercase())), "a name another library has"); + let (mut foils, mut glows) = (0, 0); + for s in lib.iter() { + let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) }; + let (g, n) = (&g.gradient, &s.name); + assert_eq!(g.method, GradientMethod::Perceptual, "{n}"); + let bands = match &g.kind { + GradientKind::Linear => { + foils += 1; + FOIL + } + GradientKind::Radial => { + glows += 1; + GLOW + } + kind => panic!("{n}: {kind:?}"), + }; + // Every stop takes its band's offset, opacity and midpoint, and none is dropped. + let shape: Vec<(f32, f32, f32)> = g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect(); + assert_eq!(shape, bands, "{n}"); + assert!(g.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{n}"); + } + assert_eq!((foils, glows), (7, 7)); + // Foils are opaque; a glow is clear at the edge. + assert!(FOIL.iter().all(|&(_, opacity, _)| opacity == 1.0)); + assert_eq!(GLOW.last().map(|b| b.1), Some(0.0)); + // Every segment's midpoint is off center and within the Gradient panel's 13 to 87%. + for bands in [FOIL, GLOW] { + assert!(bands[..bands.len() - 1].iter().all(|&(_, _, m)| (m - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&m))); + assert!(bands.windows(2).all(|w| w[0].0 < w[1].0) && bands[0].0 == 0.0 && bands[bands.len() - 1].0 == 1.0); + } + // Sampled colors: Prism Veil's bright band is the pearl anchor, and Violet Edge ends in deep violet. + let stop = |name: &str, i: usize| match &lib.swatch(name).unwrap().paint { + Paint::Gradient(g) => g.gradient.stops[i].color, + _ => panic!("{name} is a gradient"), + }; + assert_eq!(stop("Prism Veil", 2), oklch(0.97, 0.01, 90.0)); + assert_eq!(stop("Violet Edge", 4), oklch(0.42, 0.04, 275.0)); + let [l, ..] = to_oklab(&stop("Violet Edge", 4)); + assert!(l < 0.5 && to_oklab(&stop("Prism Veil", 2))[0] > 0.95, "{l}"); + } + #[test] fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() { - for b in builtins().filter(|b| b.id != "organic-fades") { + for b in builtins().filter(|b| !["organic-fades", "holographic"].contains(&b.id)) { for s in builtin_library(b.id).unwrap().iter() { if let Paint::Gradient(g) = &s.paint { assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name); diff --git a/crates/engine/src/tests/gradpresets.rs b/crates/engine/src/tests/gradpresets.rs index 807094a1bd..4328361b8b 100644 --- a/crates/engine/src/tests/gradpresets.rs +++ b/crates/engine/src/tests/gradpresets.rs @@ -40,7 +40,16 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() { libraries.dedup(); assert_eq!( libraries, - ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"] + [ + "Spectrum Gradients", + "Sky Gradients", + "Neutral Gradients", + "Organic Fades", + "Holographic", + "Duotone Gradients", + "Radial Glows", + "Metallic Gradients" + ] ); let mut seen = std::collections::HashSet::new(); assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); @@ -82,6 +91,42 @@ fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() { assert_eq!(row["gradient"]["method"], "perceptual"); } +#[test] +fn a_holographic_foil_applies_in_place_as_one_undo_step() { + let (mut s, id) = session_with(json!({"gradient": {"angle": 30}})); + let before = paint(&s, id, false); + let undo = s.doc().unwrap().history.undo.len(); + let veil = s.gradient_preset("prism veil").unwrap(); + assert_eq!((veil.library, veil.gradient.method), (Some("Holographic"), GradientMethod::Perceptual)); + let r = s.execute("gradient.presets.apply", &json!({"name": "prism veil"})).unwrap(); + assert_eq!(r["name"].as_str(), Some("Prism Veil")); + let g = paint(&s, id, false); + assert_eq!((g.gradient.kind, g.gradient.method, &g.gradient.stops), (GradientKind::Linear, GradientMethod::Perceptual, &veil.gradient.stops)); + assert_eq!(g.gradient.stops.len(), 6); + assert_eq!(g.angle, before.angle, "the placement stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.gradient_preset_name(&g.gradient).as_deref(), Some("Prism Veil")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paint(&s, id, false), before); +} + +/// A preset saved as "Mint Halo" before Holographic existed stays listed under a free name. +#[test] +fn a_saved_preset_named_like_a_holographic_glow_gets_a_free_name() { + let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}})); + let halo = s.gradient_preset("Mint Halo").unwrap().gradient; + let mut own = paint(&s, id, false).gradient; + own.dither = true; + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = vec![crate::cmd::GradientPreset { name: "MINT HALO".into(), gradient: own.clone() }]; + s.apply_prefs(prefs); + assert_eq!(s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["MINT HALO 2"]); + s.execute("gradient.presets.apply", &json!({"name": "mint halo 2"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient, own); + s.execute("gradient.presets.apply", &json!({"name": "Mint Halo"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient.stops, halo.stops); +} + #[test] fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() { let (mut s, id) = session_with(json!({"color": "#336699"})); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 55ab34564a..98585d607e 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -671,6 +671,44 @@ fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() } } +#[test] +fn holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() { + let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap(); + let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") }; + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode})); + let list = run(&mut s, "swatch.library.list", json!({})); + let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone(); + assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14))); + let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"})); + assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo"); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + let before = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}"); + assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step"); + // The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method. + let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") }; + assert_eq!(added.gradient, want.gradient, "{mode}"); + assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB"); + assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge"); + let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") }; + assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}"); + assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}"); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add"); + assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill"); + // A document swatch already named Mint Halo stays; the glow comes in under a number. + run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"})); + let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]})); + assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}"); + assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept"); + assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient)); + } +} + #[test] fn pattern_libraries_bring_their_patterns_and_add_each_once() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6b770ec1bc..1942afa9b7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2283,6 +2283,7 @@ font-technology Variable Променливи Options: Опции: Options… Опции… Organic Fades Органични преливания + Holographic Холографски or more spaces with a tab или повече интервала с табулация Order: Ред: Ordinals Поредни номера diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f6029f08ef..9b19437aaf 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -828,6 +828,7 @@ Sky Gradients Gradijenti neba Neutral Gradients Neutralni gradijenti Organic Fades Organski prelazi + Holographic Holografski Duotone Gradients Duoton gradijenti Radial Glows Radijalni sjajevi Save Swatch Library… Sačuvaj biblioteku uzoraka boja… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..ec43f19283 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2918,6 +2918,7 @@ digits Malayalam Malaiàlam Options: Opcions: Options… Opcions… Organic Fades Esvaïments orgànics + Holographic Hologràfics or more spaces with a tab o més espais amb un tabulador Orange Taronja Order: Ordre: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..321e06b088 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -837,6 +837,7 @@ Sky Gradients Přechody oblohy Neutral Gradients Neutrální přechody Organic Fades Organické přechody + Holographic Holografické Duotone Gradients Dvoutónové přechody Radial Glows Kruhové záře Save Swatch Library… Uložit knihovnu vzorníku… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00b46b0f22..079e0b94fd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -774,6 +774,7 @@ Sky Gradients Himmelgradienter Neutral Gradients Neutrale gradienter Organic Fades Organiske toninger + Holographic Holografiske Duotone Gradients Duotonegradienter Radial Glows Radiale gløder Save Swatch Library… Gem bibliotek med farveprøver… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..1aa6fe5c36 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2319,6 +2319,7 @@ file-filter Templates Vorlagen Options: Optionen: Options… Optionen… Organic Fades Organische Verläufe + Holographic Holografisch or more spaces with a tab oder mehr Leerzeichen durch einen Tabulator Order: Reihenfolge: Ordinals Ordinalzahlen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..ef750c939b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1842,6 +1842,7 @@ font-technology Variable Μεταβλητές Options… Επιλογές… Ordinals Τακτικοί αριθμοί Organic Fades Οργανικές διαβαθμίσεις + Holographic Ολογραφικά Orientation Προσανατολισμός Orientation: Προσανατολισμός: Original Πρωτότυπο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..84b2ac15ed 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2357,6 +2357,7 @@ font-technology Variable Variables Options: Opciones: Options… Opciones… Organic Fades Desvanecidos orgánicos + Holographic Holográficos or more spaces with a tab o más espacios por una tabulación Order: Orden: Ordinals Ordinales diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..9b9b1bd925 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -828,6 +828,7 @@ Sky Gradients Taivasliukuvärit Neutral Gradients Neutraalit liukuvärit Organic Fades Orgaaniset häivytykset + Holographic Holografiset Duotone Gradients Kaksisävyliukuvärit Radial Glows Säteittäiset hehkut Save Swatch Library… Tallenna värimallikirjasto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..6a7c71c136 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2349,6 +2349,7 @@ font-technology Variable Variables Options: Options : Options… Options… Organic Fades Fondus organiques + Holographic Holographiques or more spaces with a tab espaces ou plus par une tabulation Order: Ordre : Ordinals Ordinaux diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..2bbb082048 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3174,6 +3174,7 @@ antiAlias Sharp शार्प Options: ऑप्शन्स: Options… ऑप्शन्स… Organic Fades ऑर्गेनिक फ़ेड + Holographic होलोग्राफ़िक or more spaces with a tab या एक टैब के साथ अधिक रिक्त स्थान Orange नारंगी Order: आदेश: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..7fd9516069 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3175,6 +3175,7 @@ mark-style Japanese Japán Radial Blur… Körkörös elmosás… Radial Glows Körkörös ragyogások Organic Fades Organikus áttűnések + Holographic Holografikus Radial Gradient Körkörös színátmenet Conic Gradient Kúpos színátmenet Diamond Gradient Rombusz színátmenet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9925f7b0d8..27416fa63c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -774,6 +774,7 @@ Sky Gradients Gradien langit Neutral Gradients Gradien netral Organic Fades Pudar organik + Holographic Holografik Duotone Gradients Gradien duotone Radial Glows Pendar radial Save Swatch Library… Simpan pustaka sampel warna… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..b545d2c8fd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2395,6 +2395,7 @@ file-filter Templates Modelli Options: Opzioni: Options… Opzioni… Organic Fades Dissolvenze organiche + Holographic Olografici or more spaces with a tab o più spazi con una tabulazione Order: Ordine: Ordinals Ordinali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..490c98afe5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -860,6 +860,7 @@ digits Malayalam マラヤーラム数字 Sky Gradients 空のグラデーション Neutral Gradients ニュートラルグラデーション Organic Fades オーガニックフェード + Holographic ホログラフィック Duotone Gradients ダブルトーングラデーション Radial Glows 円形グロー Save Swatch Library… スウォッチライブラリを保存… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..4e9070fdd3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -734,6 +734,7 @@ Sky Gradients ცის გრადიენტები Neutral Gradients ნეიტრალური გრადიენტები Organic Fades ორგანული გაქრობები + Holographic ჰოლოგრაფიული Duotone Gradients ორტონიანი გრადიენტები Radial Glows რადიალური ნათებები Save Swatch Library… ფერის ნიმუშების ბიბლიოთეკის შენახვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..c268835e8a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -853,6 +853,7 @@ digits Malayalam 말라얄람어 Sky Gradients 하늘 그레이디언트 Neutral Gradients 중간색 그레이디언트 Organic Fades 유기적 페이드 + Holographic 홀로그래픽 Duotone Gradients 듀오톤 그레이디언트 Radial Glows 방사형 광선 Save Swatch Library… 견본 라이브러리 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20ea6ac94d..03030a78be 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -788,6 +788,7 @@ Sky Gradients Luchtverlopen Neutral Gradients Neutrale verlopen Organic Fades Organische vervagingen + Holographic Holografisch Duotone Gradients Duotoonverlopen Radial Glows Radiale gloed Save Swatch Library… Staalenbibliotheek opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..750886c506 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -784,6 +784,7 @@ Sky Gradients Gradienty nieba Neutral Gradients Gradienty neutralne Organic Fades Organiczne zanikania + Holographic Holograficzne Duotone Gradients Gradienty dwutonowe Radial Glows Blaski promieniste Save Swatch Library… Zapisz bibliotekę próbek… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..974b698d09 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1954,6 +1954,7 @@ antiAlias Sharp Nítido Order: Ordem: Ordinals Ordinais Organic Fades Esmaecimentos Orgânicos + Holographic Holográficos Orientation Orientação Orientation: Orientação: Original Original diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..eb98dbd431 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2382,6 +2382,7 @@ file-filter Templates Шаблоны Options: Параметры: Options… Параметры… Organic Fades Органические затухания + Holographic Голографические or more spaces with a tab или больше пробелов на табуляцию Order: Порядок: Ordinals Порядковые числительные diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..f8acf49c6e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2296,6 +2296,7 @@ font-technology Variable Variabla Options: Alternativ: Options… Alternativ… Organic Fades Organiska toningar + Holographic Holografiska or more spaces with a tab eller fler blanksteg med en tabb Order: Ordning: Ordinals Ordningstal diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..dd003c3cc9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2168,6 +2168,7 @@ shape Open Açık Options: Seçenekler: Options… Seçenekler… Organic Fades Organik Solmalar + Holographic Holografik or more spaces with a tab veya sekmeli daha fazla alan Order: Sipariş: Ordinals Sıra Sayıları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..2aa9d0bf10 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2387,6 +2387,7 @@ file-filter Templates Шаблони Options: Параметри: Options… Параметри… Organic Fades Органічні згасання + Holographic Голографічні or more spaces with a tab або більше прогалин на табуляцію Order: Порядок: Ordinals Порядкові числівники diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..0b254f73ab 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2964,6 +2964,7 @@ mark-style Japanese Kiểu Nhật Radial Dividers: Đường chia hướng tâm: Radial Glows Ánh sáng hướng tâm Organic Fades Mờ dần tự nhiên + Holographic Ảnh ba chiều Radial Gradient Chuyển sắc hướng tâm Conic Gradient Chuyển sắc hình nón Diamond Gradient Chuyển sắc hình thoi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..be6cc7ab2c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2254,6 +2254,7 @@ file-filter Templates 模板 Options: 选项: Options… 选项… Organic Fades 自然渐隐 + Holographic 全息 or more spaces with a tab 或多个空格加一个制表符 Order: 顺序: Ordinals 序数字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..5d0c0904a9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3354,6 +3354,7 @@ file-filter Templates 範本 Options: 選項: Options… 選項… Organic Fades 自然漸隱 + Holographic 全像 or more spaces with a tab 個以上的空格取代為定位字元 Order: 順序: Ordinals 序數字 diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e8c9393deb..46fde93aad 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -828,6 +828,23 @@ mod gradient_chip_tests { assert!(chip.iter().all(|c| c.a() == 0), "{chip:?}"); } + /// A Holographic glow's chip is clear at the corners and nearly opaque at the center; a foil's + /// chip is opaque everywhere. + #[test] + fn holographic_chips_show_a_glow_clear_at_its_edge_and_a_foil_opaque() { + use vectorcraft_color::Paint; + let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap(); + let chip = |name: &str| match &lib.swatch(name).unwrap().paint { + Paint::Gradient(g) => gradient_chip_colors(g, 20.0, 20.0, 4, 4), + p => panic!("{name}: {p:?}"), + }; + let glow = chip("Mint Halo"); + assert!([0, 4, 20, 24].iter().all(|&i| glow[i].a() == 0), "{glow:?}"); + assert!(glow[2 * 5 + 2].a() >= 240, "{glow:?}"); + let foil = chip("Prism Veil"); + assert!(foil.iter().all(|c| c.a() == 255), "{foil:?}"); + } + #[test] fn freeform_chip_mixes_points_in_the_model_they_share() { use vectorcraft_color::{Color, Freeform, FreeformPoint}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..738039e9ae 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1288,7 +1288,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From b262d10e8970bed56a265c277c3bdb0eae1c0613 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:39:32 -0800 Subject: [PATCH 105/126] Save and Export: files written beside the picked one ask before replacing a file there A save or export that writes a file per artboard, linked images or image maps names them after the file picked in the save panel, which asked only about that name. A file already there under one of the other names was replaced without a question. Save As, Save a Copy, Save as Template, Save and Export now check those files before writing anything. When any is there, nothing is written and a replaceFiles question names it (and lists all of them when there are several). OK runs the save or export again on the document that asked and replaces the named files; a file it would now write that the question did not name is asked about again. Cancel writes nothing. A background save or export checks from its worker and asks once it ends, after a dialog open then. Files the document's last Save or Save As wrote are replaced without asking. Services::exists is now an Arc<dyn Fn(&str) -> bool + Send + Sync>. --- apps/vectorcraft/src/host_services.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 11 ++- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/confirm.rs | 46 ++++++++++++ crates/ui-egui/src/io/export.rs | 17 ++++- crates/ui-egui/src/io/save.rs | 64 +++++++++++++++- crates/ui-egui/src/picks.rs | 10 ++- crates/ui-egui/src/services.rs | 8 +- crates/ui-egui/src/tests/background.rs | 23 ++++++ crates/ui-egui/src/tests/nativeoptions.rs | 92 +++++++++++++++++++++++ crates/ui-egui/src/tests/picks.rs | 4 +- crates/ui-egui/src/tests/saveext.rs | 10 +-- docs/control-protocol.md | 2 +- 13 files changed, 270 insertions(+), 20 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde7..acaabca7b6 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -355,7 +355,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Load Actions… refuses a file over its size limit without reading all of it. read_head: Some(Box::new(read_file_head)), write: Some(Box::new(write_file)), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), // Native saves stream into the file as they are encoded. diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..bc54ba9acc 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -883,7 +883,13 @@ impl SaveJob { /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: /// [path…], linked?: [path…]}`. - pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { + pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { + self.write_unless(|_| None, write) + } + + /// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in + /// order: when `stop` returns a value, nothing is written and that value is returned. + pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; let warnings = std::mem::take(&mut enc.warnings); @@ -897,6 +903,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) { + return Ok(stopped); + } for (p, bytes) in &files { write(p, bytes)?; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..91efcffa3d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -324,6 +324,7 @@ registry! { BrushDelete: [brush_delete::KIND] => brush_delete::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, + ReplaceFiles: [confirm::REPLACE_FILES] => confirm::REPLACE_FILES_SPEC, ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 06e96e45fe..6f51be44d9 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -63,6 +63,52 @@ pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) { app.ui.dialog = Some(Dialog::new(REPLACE, fields)); } +/// The dialog kind of the question whether a save or export may replace the files it writes beside +/// the one picked in the save panel ([`ask_replace_files`]). Fields: `message`, `detail`, `files` +/// (their paths), `allowed` (files an earlier OK allowed), `doc` (the document's uid), `__command` +/// and `__params`. +pub const REPLACE_FILES: &str = "replaceFiles"; + +pub(super) const REPLACE_FILES_SPEC: DialogSpec = DialogSpec { confirm: replace_files, ..SPEC }; + +/// Ask whether to replace `files`, files that are there beside the one a save or export of the +/// document `doc` (its uid) writes, which the save panel did not ask about (a file per artboard, a +/// linked image). OK runs `command` with `params` again on that document, now replacing those +/// files and `allowed` (those an earlier OK allowed); Cancel writes nothing. +pub(crate) fn ask_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: Value) { + let name = files.first().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).unwrap_or_default(); + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", &name)]); + // Several: every one of them, by name. + let detail = match files { + [_, _, ..] => files.iter().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).collect::<Vec<_>>().join("\n"), + _ => String::new(), + }; + let fields = + json!({"message": message, "detail": detail, "files": files, "allowed": allowed, "doc": doc, "__command": command, "__params": params}); + let d = Dialog::new(REPLACE_FILES, fields); + // Asked when a Background Save or Export ends: a dialog open then stays, and this waits its turn + // (however many messages wait: the save or export needs the answer). + match app.ui.dialog { + None => app.ui.dialog = Some(d), + Some(_) => app.ui.told.push(d), + } +} + +/// OK on [`ask_replace_files`]: what asked runs again on its document, and the files the question +/// named are replaced; one it writes now that was not named is asked about again. The save or export +/// reads the allowance when it starts, before a background one hands its work to the worker. +fn replace_files(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { + app.ui.dialog = None; + if !crate::picks::on_document(app, d.fields.get("doc").and_then(Value::as_u64)) { + return Ok(Value::Null); + } + let paths = |k: &str| d.fields.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string); + app.picks.replace_files_ok = paths("allowed").chain(paths("files")).collect(); + let r = confirm(app, d); + app.picks.replace_files_ok.clear(); + r +} + /// The dialog kind of the question Reset Preferences asks over the Preferences dialog /// ([`ask_reset_prefs`]). Fields: `message`, `detail`. pub const RESET_PREFS: &str = "resetPreferences"; diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 562dc0e00a..1b15b1cbed 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,6 @@ use super::save::{ - ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path, - target_path_keeping, view_file, write_encoded, write_named, write_reporting, + ask_before_losing, ask_to_replace_files, checks_replaced, export_path, is_web, remember_active_artboard, replaced_files, report_saved, + report_write_error, saved_status, target_path, target_path_keeping, view_file, with_path, write_encoded, write_named, write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -44,6 +44,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), }; let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); + let uid = st.uid; let path = export_path(app, path, f)?; let mut again = params.clone(); if let Some(o) = again.as_object_mut() { @@ -68,11 +69,18 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin // writing. let progress = Progress::new(); let (reporting, asked) = (progress.clone(), progress.clone()); + let (exists, allowed) = checks_replaced(app); + let allowed_before = allowed.clone(); + let rerun = with_path(&again, &path); let work = move |write: Writer| { progress::scope(&reporting, || { cancel::check().map_err(|e| e.to_string())?; let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?; cancel::check().map_err(|e| e.to_string())?; + let replaced = replaced_files(exists.as_ref(), &path, &allowed, enc.named(&doc, &path).iter().map(|(p, _)| p.as_str())); + if !replaced.is_empty() { + return Ok(json!({ "replaces": replaced })); + } let files = write_encoded(write, &doc, &path, &enc)?; let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); if files.len() > 1 { @@ -90,6 +98,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin return Ok(json!({ "cancelled": true })); } let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?; + // Nothing written: asked first. + if let Some(files) = r.get("replaces").and_then(Value::as_array) { + let files: Vec<String> = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed_before, "file.exportAs", &rerun)); + } let what = match r["files"].as_array() { Some(files) => format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..71ae015a48 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -264,7 +264,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, (save_command(mode), with_path(p, &path))); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -291,7 +291,42 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, (save_command(mode), q)) +} + +/// `p` (an object, else none) with `path`. +pub(super) fn with_path(p: &Value, path: &str) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["path"] = json!(path); + p +} + +/// The files among `paths` that a write to `path` replaces although the save panel asked only +/// about `path`: every other one that `exists` finds, but those in `known`. None without `exists`. +pub(super) fn replaced_files<'a>( + exists: Option<&crate::ExistsFn>, + path: &str, + known: &[String], + paths: impl IntoIterator<Item = &'a str>, +) -> Vec<String> { + let Some(exists) = exists else { return Vec::new() }; + paths.into_iter().filter(|p| *p != path && !known.iter().any(|k| k == p) && exists(p)).map(str::to_string).collect() +} + +/// The `exists` service with which a save or export checks the files it writes beside the one +/// picked ([`replaced_files`]), none on the web (downloads), and the files it replaces without +/// asking: those the OK of the question about them allowed while it runs that again. +pub(super) fn checks_replaced(app: &VectorcraftApp) -> (Option<crate::ExistsFn>, Vec<String>) { + (app.services.exists.clone().filter(|_| !is_web(app)), app.picks.replace_files_ok.clone()) +} + +/// Ask before a save or export of the document `doc` (its uid) replaces `files`, files that are +/// there beside the one picked in the save panel (a file per artboard, a linked image), which the +/// panel did not ask about. OK runs `command` with `params` again, replacing them and `allowed` +/// (those an earlier OK allowed); Cancel writes nothing → `{pending}`. +pub(super) fn ask_to_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: &Value) -> Value { + dialogs::confirm::ask_replace_files(app, doc, files, allowed, command, params.clone()); + json!({ "pending": dialogs::confirm::REPLACE_FILES }) } /// The UI command that runs a save in `mode`. @@ -418,7 +453,10 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> { +/// A file it writes beside the one at its path that is there already, and that the document's last +/// save did not write, is asked about before anything is written; `again` (a command and its params) +/// runs the save again once the question's OK allows it ([`ask_to_replace_files`]). +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Value)) -> Result<Value, String> { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -446,10 +484,19 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> let done = job.clone(); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let (exists, allowed) = checks_replaced(app); + let known: Vec<String> = allowed.iter().chain(app.picks.written_beside.get(&uid).into_iter().flatten()).cloned().collect(); let work = move |write: Writer| { match stream { Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + None => job.write_unless( + |paths| { + let path = job.plan().path.as_deref().unwrap_or_default(); + let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied()); + (!replaced.is_empty()).then(|| json!({ "replaces": replaced })) + }, + |p, b| write(p, b).map_err(EngineError::Other), + ), } .map_err(|e| e.to_string()) }; @@ -457,11 +504,20 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| { // Not written: the document keeps its file, format and modified state, and the user is told. let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + // Nothing written either: asked first. + if let Some(files) = r.get("replaces").and_then(Value::as_array) { + let files: Vec<String> = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed, again.0, &again.1)); + } // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); if retargets { note_recent(app, &path); + // The files written beside the document's file, which its next save replaces. + let beside = ["files", "linked"].iter().flat_map(|k| r[*k].as_array().into_iter().flatten()).filter_map(Value::as_str); + let beside: Vec<String> = beside.filter(|p| *p != path).map(str::to_string).collect(); + app.picks.written_beside.insert(uid, beside); } report_saved(app, &path, &r); Ok(r) diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..d9801de721 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -122,6 +122,13 @@ pub(crate) struct Picks { replacing: Option<Replacing>, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option<String>, + /// The files that the OK of the question whether to replace the files a save or export writes + /// beside the one picked allows to be replaced while it runs that again + /// ([`crate::dialogs::confirm::ask_replace_files`]). + pub(crate) replace_files_ok: Vec<String>, + /// The files each document's last save wrote beside its own file, by document uid: its next + /// save replaces them without asking. + pub(crate) written_beside: std::collections::HashMap<u64, Vec<String>>, } /// A save dialog's answer that names, once the app gave it an extension, a file that is there. @@ -152,6 +159,7 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.replace_files_ok.clear(); } /// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`). @@ -388,7 +396,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Make the document an entry asked for (`doc`, by its uid) active again before it runs again: /// another may be active by now (opened from the Finder, by an agent), or it may be gone → false /// when it can't run (the status bar says why). -fn on_document(app: &mut VectorcraftApp, doc: Option<u64>) -> bool { +pub(crate) fn on_document(app: &mut VectorcraftApp, doc: Option<u64>) -> bool { let Some(uid) = doc else { return true }; let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else { app.status(tl!("The document was closed while its file dialog was open")); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..d2f3424103 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,7 +43,8 @@ pub type ReadHeadFn = Box<dyn Fn(&str, usize) -> Result<Vec<u8>, String>>; pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>; -pub type ExistsFn = Box<dyn Fn(&str) -> bool>; +/// Shared, so Background Save and Export can check from a worker thread too. +pub type ExistsFn = Arc<dyn Fn(&str) -> bool + Send + Sync>; pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>; @@ -86,8 +87,9 @@ pub struct Services { pub read_head: Option<ReadHeadFn>, pub write: Option<WriteFn>, /// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a - /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). - /// Without it, no file counts as there. + /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]), and + /// before a save or export replaces a file it writes beside that one (a file per artboard, a + /// linked image). Without it, no file counts as there. pub exists: Option<ExistsFn>, /// Files that arrived asynchronously (web open / drops): (name, bytes). pub inbox: Option<Inbox>, diff --git a/crates/ui-egui/src/tests/background.rs b/crates/ui-egui/src/tests/background.rs index add53156e8..a71a55d1fe 100644 --- a/crates/ui-egui/src/tests/background.rs +++ b/crates/ui-egui/src/tests/background.rs @@ -122,6 +122,29 @@ fn exports_run_in_the_background_and_the_preference_turns_both_off() { assert!(app.background.jobs.is_empty()); } +/// A Background Export with a file per artboard checks for files there from its worker: one with +/// an artboard file's name is asked about once the export ends, before anything is written, and +/// the question's OK writes every file. +#[test] +fn a_background_export_asks_before_replacing_an_artboard_file() { + let gate = Gate::default(); + let (mut app, files) = app(gate.clone()); + app.services.exists = Some(Arc::new(|p: &str| p == "/out/a-B.png")); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 80, "name": "B"})).unwrap(); + let r = io::export(&mut app, Some("png"), Some("/out/a.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["background"].as_bool(), Some(true), "{r}"); + gate.open(); + background::wait_all(&mut app); + assert!(files.lock().unwrap().is_empty(), "nothing is written while it asks"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::REPLACE_FILES, "“a-B.png” already exists. Replace it?")); + crate::dialogs::confirm(&mut app).unwrap(); + background::wait_all(&mut app); + let names: Vec<String> = files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["/out/a-Artboard-1.png", "/out/a-B.png"]); +} + /// A save that returns before the file is written still says what it writes and which options it /// does not use, as `app.save` (MCP's `save_file`) replies with it: what the format loses first, as /// the reply of a save written at once. diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 5f136d514c..e97443eb36 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -64,6 +64,98 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { assert_eq!(written.borrow().len(), 10); } +/// The paths written so far. +fn names(written: &crate::tests_svg::Written) -> Vec<String> { + written.borrow().iter().map(|(p, _)| p.clone()).collect() +} + +/// Save As with a file per artboard: a file there with an artboard file's name, which the save +/// panel did not ask about, is asked about before anything is written. Cancel writes nothing; OK +/// writes every file, and Save to the document's own file then writes them again without asking. +#[test] +fn save_as_asks_before_replacing_an_artboard_file_that_is_there() { + let (mut app, written) = app("/tmp/doc.vectorcraft"); + three_boards(&mut app); + let there = ["/tmp/doc-B.vectorcraft", "/tmp/doc-D.vectorcraft", "/tmp/doc-E.vectorcraft"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let pick = |app: &mut crate::VectorcraftApp| { + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("separateArtboards".into(), json!(true)); + d.fields.insert("all".into(), json!(true)); + dialogs::confirm(app).unwrap() + }; + let r = pick(&mut app); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::REPLACE_FILES, "“doc-B.vectorcraft” already exists. Replace it?")); + assert_eq!(d.fields["files"], json!(["/tmp/doc-B.vectorcraft"])); + assert!(written.borrow().is_empty(), "nothing is written while it asks"); + dialogs::cancel(&mut app); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(app.session.active().unwrap().path.is_none()); + pick(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/doc.vectorcraft", "/tmp/doc-Artboard-1.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft")); + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 8); + // A new artboard's file is not one the last save wrote: Save asks about it alone. + app.run("artboard.new", json!({"x": 600, "y": 0, "width": 100, "height": 80, "name": "D"})).unwrap(); + let r = app.run("file.save", Value::Null).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-D.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + // An artboard added while it asks writes a file the question did not name: OK asks about it. + app.run("artboard.new", json!({"x": 800, "y": 0, "width": 100, "height": 80, "name": "E"})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-E.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(written.borrow().len(), 14); + assert!(!app.session.active().unwrap().is_dirty()); +} + +/// A question asked when a Background Save or Export ends waits for a dialog open then to close. +#[test] +fn the_question_waits_for_an_open_dialog() { + let (mut app, _) = app("/tmp/doc.vectorcraft"); + let uid = app.session.active().unwrap().uid; + dialogs::confirm::tell(&mut app, "Open", ""); + dialogs::confirm::ask_replace_files(&mut app, uid, &["/tmp/doc-B.vectorcraft".into()], &[], "file.save", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("message")), Some("Open".to_string())); + dialogs::cancel(&mut app); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE_FILES)); +} + +/// Export with a file per artboard asks the same way, naming every file that is there; OK writes +/// them. +#[test] +fn export_asks_before_replacing_artboard_files_that_are_there() { + let (mut app, written) = app("/tmp/art.png"); + three_boards(&mut app); + let there = ["/tmp/art-B.png", "/tmp/art-C.png"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let r = crate::io::export(&mut app, Some("png"), Some("/tmp/art.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("message").as_str(), d.str("detail").as_str(), d.str("__command").as_str()), + ("“art-B.png” already exists. Replace it?", "art-B.png\nart-C.png", "file.exportAs") + ); + assert!(written.borrow().is_empty()); + // Another document made active meanwhile: OK exports the one that asked. + let uid = app.session.active().unwrap().uid; + let question = app.ui.dialog.take(); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.ui.dialog = question; + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/art-Artboard-1.png", "/tmp/art-B.png", "/tmp/art-C.png"]); + assert_eq!(app.session.active().map(|d| d.uid), Some(uid)); +} + #[test] fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() { let (mut app, written) = app("/tmp/plain.vectorcraft"); diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index c1e3f822cd..90e6fdc78d 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -539,7 +539,7 @@ fn preset_dialogs_export_asks_off_the_ui_thread() { #[test] fn a_sheets_answer_given_its_extension_asks_before_replacing() { let (mut app, shown, written) = app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); assert!(app.run("file.save", json!({})).is_err()); shown.answer(&["/art/card"]); @@ -566,7 +566,7 @@ fn a_preset_dialogs_export_asks_before_replacing_over_it() { w.borrow_mut().push(path.to_string()); Ok(()) })), - exists: Some(Box::new(|_: &str| true)), + exists: Some(std::sync::Arc::new(|_: &str| true)), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 497ac0c749..b8bb2ee6e4 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -215,7 +215,7 @@ fn a_typed_extension_of_a_format_the_save_does_not_write_is_replaced() { #[test] fn a_replaced_extension_asks_before_replacing_a_file_there() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/card.png"]), "/art/card.vectorcraft"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -254,7 +254,7 @@ fn a_document_in_another_format_saves_as_the_type_picked() { #[test] fn a_name_given_the_picked_types_extension_asks_before_replacing() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/poster.ai")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/poster.ai")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/poster", "ai"]), "/art/poster.ai"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -284,7 +284,7 @@ fn on_disk(tag: &str, typed: &str) -> (VectorcraftApp, std::path::PathBuf) { let picked = dir.join(typed).to_string_lossy().into_owned(); let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), pick_save: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), ..Default::default() }; @@ -371,7 +371,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { ]; for (cmd, params, path) in cases { let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("select.all", json!({})).unwrap(); assert!(app.run(cmd, params).is_err(), "{cmd}"); assert!(w.borrow().is_empty(), "{cmd}: nothing is written while it asks"); @@ -382,7 +382,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { } // Export As: the question's OK picks the file again, and PNG Options follows. let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("file.exportAs", json!({"format": "png"})).unwrap(); assert!(dialogs::confirm(&mut app).is_err()); let open = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.str("path"))); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dbaf2b48ab..e668b4a423 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -111,7 +111,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | From a2b95882172202dfe7a87735701cee594c1364f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:42:32 -0800 Subject: [PATCH 106/126] Docs: Paragraph Style Options lists the East Asian Composition Settings section --- docs/mcp.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 4987959351..edf52fa9aa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7881,8 +7881,8 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `composition`, `hyphenation` or -`justification`) and `preview`, and these fields: +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `composition`, `hyphenation`, +`justification` or `eastAsian`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and @@ -7894,6 +7894,8 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `co - Justification: `word_min`, `word_desired`, `word_max`, `letter_min`, `letter_desired`, `letter_max`, `glyph_min`, `glyph_desired` and `glyph_max` (percent), `auto_leading` (percent) and `single_word` (`fullJustify`, `alignLeft`, `alignCenter` or `alignRight`). +- East Asian Composition Settings: the `korean_line_break_anywhere` checkbox (Korean Composition Settings › Korean + Line Break Anywhere). A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes @@ -7906,8 +7908,9 @@ Glyph Scaling outside 50 to 200%, Minimum, Desired and Maximum values out of tha style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the five distances, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and -`hyphenation`, and -Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section +`hyphenation`, +Justification removes `justification`, and East Asian Composition Settings removes `korean_line_break_anywhere`. +`ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every From 675a8a35af06ebab225de594703165f63388d8eb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:43:50 -0800 Subject: [PATCH 107/126] Docs: 3D remaining-work rows no longer list Revolve (Classic) The 3D and Materials rows of target-app-parity.md's area tables now name Rotate (Classic) and Map Art as the 3D (Classic) work that remains, as effects-parity.md and gaps.md do. --- docs/target-app-parity.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..0c1fb53790 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -220,7 +220,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -228,7 +228,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | @@ -490,10 +490,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | From 08de741ca9530dc6e63948b06ce69ec1bfcde756 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:42:24 -0800 Subject: [PATCH 108/126] 3D (Classic): Rotate (Classic) turns flat art in 3D with the No Shading and Diffuse surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect › 3D and Materials › 3D (Classic) › Rotate (Classic)… turns every fill and stroke as one flat plane, as Rotate does, and shades it with the classic No Shading or Diffuse surface. - `effect.apply` with `threeD.classicRotate`: the Position rotation and Perspective, `surface` (`none` or `diffuse`), the classic lights, Ambient Light, Blend Steps and Shading Color. It has no highlight, no Draw Hidden Faces and no shadow. - The dialog shows Position on the Rotation tab and the surface and lights on the Lighting tab, with no Object tab. - Groups and layers turn about one shared center, and the effect also goes on a fill or stroke, as Rotate's does. - `effect.lightingPreset` refuses it, as it refuses the other classic 3D effects. - Unknown parameters, numbers out of range, bad flags, a surface other than No Shading or Diffuse and bad shading colors are refused with an error. --- crates/effects/src/catalog.rs | 10 ++- crates/effects/src/lib.rs | 8 +- crates/effects/src/solids.rs | 35 ++++++--- crates/effects/src/solids/classic.rs | 29 ++++++- crates/effects/src/solids/source.rs | 2 +- crates/effects/src/solids/tests.rs | 77 +++++++++++++++++++ crates/engine/src/cmd/effectcmd.rs | 4 +- .../cmd/effectcmd/lighting_presets_tests.rs | 14 ++++ .../src/cmd/effectcmd/three_d_source_tests.rs | 2 +- crates/ui-egui/src/dialogs/effect.rs | 5 +- crates/ui-egui/src/dialogs/revolve.rs | 10 +-- crates/ui-egui/src/dialogs/revolve/tests.rs | 38 +++++++++ crates/ui-egui/src/dialogs/revolve_gizmo.rs | 6 +- crates/ui-egui/src/dialogs/three_d_classic.rs | 41 ++++++---- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/tests.rs | 16 ++++ docs/effects-parity.md | 4 +- docs/gaps.md | 2 +- docs/mcp.md | 29 ++++++- docs/three-d.md | 11 +-- 46 files changed, 313 insertions(+), 57 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 81c09494b9..866d9b32f3 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,5 +1,6 @@ use super::{ - CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap, + CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, + json, trap, }; /// Catalogue entry for one effect. @@ -217,6 +218,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), ), + g( + CLASSIC_ROTATE, + "Rotate (Classic)…", + &["Effect", "3D and Materials", "3D (Classic)"], + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + ), g( "convertToShape.rectangle", "Rectangle…", diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f92515580c..92ee086147 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,10 +87,10 @@ pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, }; pub use solids::{ - CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, - classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, - three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, - validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, + classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, + validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7502dad469..8d9ff9eea8 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1,4 +1,4 @@ -//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art, +//! Extrude & Bevel, Inflate, Rotate, Extrude & Bevel (Classic) and Rotate (Classic) as live, shaded vector art, //! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. use std::sync::Arc; @@ -15,7 +15,7 @@ use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry}; pub(crate) mod classic; -pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; +pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLORS, classic_extrude_options, classic_shading}; mod source; pub(crate) use source::{resolve, resolve_for}; @@ -26,13 +26,24 @@ pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; /// The 3D effects this module draws (Revolve has its own). -pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE]; +pub const SOLIDS: [&str; 5] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE, CLASSIC_ROTATE]; /// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)? pub fn is_three_d(id: &str) -> bool { crate::revolve::is_revolve(id) || SOLIDS.contains(&id) } +/// Is `id` one of the 3D (Classic) effects: Extrude & Bevel (Classic), Revolve (Classic) or Rotate +/// (Classic)? +pub fn is_classic_three_d(id: &str) -> bool { + id == CLASSIC_EXTRUDE || id == crate::CLASSIC_REVOLVE || id == CLASSIC_ROTATE +} + +/// Does 3D effect `id` turn the art as a flat plane: Rotate or Rotate (Classic)? +fn is_plane(id: &str) -> bool { + id == ROTATE || id == CLASSIC_ROTATE +} + /// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). /// Plane keeps the art flat, which is what Rotate draws. pub const THREE_D_TYPES: [(&str, &str); 4] = [("plane", ROTATE), ("extrude", EXTRUDE), ("revolve", crate::REVOLVE), ("inflate", INFLATE)]; @@ -193,6 +204,9 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { if id == CLASSIC_EXTRUDE { classic::validate(p)?; } + if id == CLASSIC_ROTATE { + classic::validate_rotate(p)?; + } crate::lighting::validate(p)?; shadow::validate(p)?; if let Some(v) = p.get("bevel") @@ -225,7 +239,7 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); - let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; + let n = &*source::resolve_for(doc, n, label, is_plane(id))?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; @@ -708,12 +722,12 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurface /// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on /// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the /// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow -/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past +/// off, for Extrude & Bevel (Classic) and Rotate (Classic) or with no shapes. A stroke's solid that is not built (past /// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error. /// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to /// the part of the shadow they cast too ([`shadow::scoped_art`]). fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> { - if id == CLASSIC_EXTRUDE { + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { return Ok(None); } let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; @@ -775,12 +789,12 @@ type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); - let classic = fx.id == CLASSIC_EXTRUDE; + let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; let colored = !classic && view_options(&p).has_colored_lights(); let masks = has_masks(shapes); // The paint copies of every solid together: past the allowance the source path is drawn. let mut paint_bytes = 0usize; - if fx.id == ROTATE { + if is_plane(&fx.id) { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. let mut out = vec![]; @@ -792,7 +806,8 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { return None; } - out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored)))); + let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + out.push((s.scopes.as_slice(), Arc::new(art))); } } return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); @@ -1001,7 +1016,7 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) let view = view_options(&p); let mut out = vec![]; let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit); - if fx.id == ROTATE { + if is_plane(&fx.id) { for s in shapes { for item in &s.items { let (paint, opacity, _) = paint_of(item, s.opacity); diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index ef2a39e015..919de831ce 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -1,6 +1,6 @@ //! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic //! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading -//! color. +//! color. Rotate (Classic): the plane Rotate turns, drawn with the No Shading and Diffuse surfaces. use std::sync::Arc; use serde_json::Value; @@ -12,6 +12,10 @@ use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Ext use crate::util::{flag, num, text}; pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude"; +pub const CLASSIC_ROTATE: &str = "threeD.classicRotate"; + +/// The surfaces Rotate (Classic) takes: No Shading and Diffuse. +pub const ROTATE_SURFACES: [&str; 2] = ["none", "diffuse"]; /// The width of a Wireframe edge, in points. const WIRE: f64 = 0.5; @@ -107,6 +111,29 @@ pub fn validate(p: &Value) -> Result<(), String> { validate_classic(CLASSIC_EXTRUDE, p, &RANGES, &FLAGS) } +/// Rotate (Classic)'s numeric parameters: (key, lowest, highest, whole numbers only). +const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +/// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude & +/// Bevel (Classic)'s, with `surface` No Shading or Diffuse only. +pub fn validate_rotate(p: &Value) -> Result<(), String> { + validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?; + if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) { + return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", "))); + } + Ok(()) +} + /// Check the parameters `p` of classic 3D effect `id` (its defaults filled in): every key is one of /// its parameters, the numbers `ranges` lists, as (key, lowest, highest, whole numbers only), are in /// range, the `flags` are true or false, and `surface`, `shadingColor` and `shadingCustom` name a diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 66ca2c1d72..585f23bd6b 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -55,7 +55,7 @@ fn needs(n: &Node) -> bool { /// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut /// for the solid effect `n` carries ([`resolve_for`]). pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> { - let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); + let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| super::is_plane(&fx.id)); resolve_for(doc, n, label, every_fill) } diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 248473f428..c960bbe671 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -889,3 +889,80 @@ fn classic_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// The red square, stroked black, with Rotate (Classic) and `params`: its surfaces, the fill's +/// plane first, then the stroke's outline. +fn classic_rotate(params: Value) -> Vec<Node> { + let mut n = square_node(); + n.appearance.effects.push(fx(CLASSIC_ROTATE, params)); + solid_art(&n).unwrap().children().unwrap().iter().map(|s| (**s).clone()).collect() +} + +#[test] +fn classic_rotate_turns_every_fill_and_stroke_as_one_plane() { + let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0}); + let surfaces = classic_rotate(front.clone()); + assert_eq!(surfaces.len(), 2, "the fill's plane and the stroke's outline"); + assert!(surfaces.iter().all(|s| s.children().unwrap().len() == 1), "each is one flat face"); + let fill = surfaces[0].visual_bounds().unwrap(); + assert!((fill.width() - 100.0).abs() < 1.0 && (fill.height() - 100.0).abs() < 1.0, "{fill:?}"); + // Turned 60° about the vertical axis, the square shows half its width and all its height, + // about the center of its bounds. + let turned = classic_rotate(json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}))[0].visual_bounds().unwrap(); + assert!((turned.width() - 50.0).abs() < 1.0 && (turned.height() - 100.0).abs() < 1.0, "{turned:?}"); + assert!((turned.center().x - 150.0).abs() < 1.0, "{turned:?}"); + // Turned past 90°, the back of the plane is drawn. + let back = classic_rotate(json!({"rotationX": 0, "rotationY": 150, "rotationZ": 0})); + assert!(back[0].visual_bounds().is_some_and(|b| (b.width() - 100.0 * 30f64.to_radians().cos()).abs() < 1.0)); +} + +#[test] +fn classic_rotate_shades_with_no_shading_or_diffuse() { + let rgb = |p: Value| fill_rgb(&classic_rotate(p)[0].children().unwrap()[0]); + // No Shading keeps the paint, the color itself kept. + let mut n = square_node(); + n.appearance.effects.push(fx(CLASSIC_ROTATE, json!({"surface": "none"}))); + let art = solid_art(&n).unwrap(); + let AppearanceItem::Fill(f) = &faces(&art)[0].appearance.items[0] else { panic!("a fill first") }; + assert_eq!(f.paint, Paint::solid(RED)); + // Diffuse: straight on with the light in front, the face is fully lit; turned away from the + // light, it is the red darkened toward black. + let lit = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "lightHeight": 90}); + assert!(close3(rgb(lit), RED.to_rgb())); + let [r, g, b] = rgb(json!({"rotationX": 0, "rotationY": 70, "rotationZ": 0, "lightHeight": 90})); + assert!(r < 0.8 - 0.05 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4, "{r} {g} {b}"); + // No light: the ambient share of the red. A custom shading color darkens toward that color; + // None keeps the paint. + let dark = rgb(json!({"lightIntensity": 0, "ambient": 20, "blendSteps": 256})); + assert!((dark[0] - 0.8 * 0.2).abs() < 0.01, "{dark:?}"); + let blue = rgb(json!({"lightIntensity": 0, "ambient": 20, "shadingColor": "custom", "shadingCustom": "#0000ff"})); + assert!(blue[2] > blue[0] / 8.0 + 0.01, "{blue:?}"); + assert!(close3(rgb(json!({"lightIntensity": 0, "shadingColor": "none"})), RED.to_rgb())); +} + +#[test] +fn classic_rotate_parameters_are_checked() { + let n = square_node(); + let check = |p: Value| validate_solid(&n, CLASSIC_ROTATE, &crate::merged_params(CLASSIC_ROTATE, &p)); + assert!(check(json!({})).is_ok()); + assert!(check(json!({"surface": "none", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 1})).is_ok()); + for (p, says) in [ + (json!({"surface": "plastic"}), "`surface` must be one of none, diffuse"), + (json!({"surface": "wireframe"}), "`surface` must be one of none, diffuse"), + (json!({"surface": 2}), "`surface` must be one of"), + (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"), + (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"rotationX": 181}), "`rotationX` must be between -180 and 180 (got 181)"), + (json!({"perspectiveAngle": 161}), "`perspectiveAngle` must be between 0 and 160 (got 161)"), + (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"), + (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), + (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"), + (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), + (json!({"shadow": true}), "unknown parameter `shadow`"), + (json!({"lights": []}), "lights must contain one to ten lights"), + ] { + let err = check(p.clone()).unwrap_err(); + assert!(err.starts_with(says), "{p}: {err}"); + } +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 566c3cd24a..657d08299b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic) or Revolve (Classic), which have no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -657,7 +657,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); } // The classic 3D effects have no lighting presets; their lights are set in their own options. - if let Some(e) = effect.filter(|e| e.id == effects::CLASSIC_EXTRUDE || e.id == effects::CLASSIC_REVOLVE) { + if let Some(e) = effect.filter(|e| effects::is_classic_three_d(&e.id)) { let name = effects::effect_info(&e.id).map_or("This effect", |info| info.label.trim_end_matches('…')); return Err(bad(C, format!("object {}: {name} at index {index} has no lighting presets; set its lights with effect.setParams", id.0))); } diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs index 80d13dc26c..2dff0d23f1 100644 --- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs +++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs @@ -130,3 +130,17 @@ fn lighting_presets_refuse_revolve_classic() { assert_eq!(s.doc().unwrap().doc, before); assert!(!s.doc().unwrap().is_dirty()); } + +#[test] +fn lighting_presets_refuse_rotate_classic() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x":100,"y":100,"width":80,"height":60})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicRotate","item":null,"params":{"lightRotation":40}})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"diffuse"})).unwrap_err().to_string(); + assert!(err.contains(&format!("object {id}: Rotate (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); +} diff --git a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs index 122d937b98..104363a142 100644 --- a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs +++ b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs @@ -4,7 +4,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use crate::Session; -const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"]; +const TYPES: [&str; 6] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.classicRotate", "threeD.revolve"]; fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index fdf6eb860b..caa9dfdc91 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -400,7 +400,8 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&' (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "surface") => { vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] } - (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor") => { + (vectorcraft_effects::CLASSIC_ROTATE, "surface") => vec![(tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse")], + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE | vectorcraft_effects::CLASSIC_ROTATE, "shadingColor") => { vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")] } (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ @@ -970,6 +971,8 @@ mod tests { (vectorcraft_effects::CLASSIC_REVOLVE, "edge"), (vectorcraft_effects::CLASSIC_REVOLVE, "surface"), (vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor"), + (vectorcraft_effects::CLASSIC_ROTATE, "surface"), + (vectorcraft_effects::CLASSIC_ROTATE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 925a593c91..c7a306887e 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,5 +1,5 @@ //! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, -//! and Extrude & Bevel (Classic) and Revolve (Classic), whose own controls are in +//! and Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), whose own controls are in //! `three_d_classic`. The object and the rotation and light gizmos live on the artboard. All document changes run through the effect //! dialog's existing preview/commit commands. @@ -146,11 +146,11 @@ pub(super) fn c3(s: &'static str) -> &'static str { } /// Extrude & Bevel, Inflate, Extrude & Bevel (Classic) and Revolve (Classic): Object, Rotation and -/// Lighting tabs (Rotate has no Object tab). The rotation gizmo shows on the first two, the light -/// gizmo on Lighting. +/// Lighting tabs (Rotate and Rotate (Classic) have no Object tab). The rotation gizmo shows on the +/// first two, the light gizmo on Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let classic = id == CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE; - let object = id == EXTRUDE || id == INFLATE || classic; + let classic = vectorcraft_effects::is_classic_three_d(id); + let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index 8a2ffb3066..fd802f83f0 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -422,6 +422,44 @@ fn classic_revolve_shows_its_options_and_applies_as_one_step() { assert_eq!(h.app.session.doc().unwrap().doc, before); } +/// Rotate (Classic): Position on the Rotation tab, with no Object tab, and No Shading or Diffuse +/// on the Lighting tab, without the highlight or Draw Hidden Faces; OK keeps the edit as one undo +/// step. +#[test] +fn classic_rotate_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_ROTATE; + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_ROTATE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Rotation", "Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Object" || t == "Depth" || t == "Angle"), "no Object tab"); + h.click(h.rect("Lighting").center()); + h.settle(); + for text in ["Surface", "Diffuse", "Light Intensity", "Ambient Light", "Blend Steps", "Shading Color"] { + h.rect(text); + } + assert!( + !h.texts.iter().any(|(t, _)| t == "Highlight Intensity" || t == "Highlight Size" || t == "Draw Hidden Faces"), + "no highlight and no hidden faces" + ); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("surface".into(), json!("none")); + d.fields.insert("rotationY".into(), json!(40.0)); + h.settle(); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_ROTATE); + assert_eq!((fx.params["surface"].clone(), fx.params["rotationY"].as_f64()), (json!("none"), Some(40.0))); + assert!(fx.params.get("highlightSize").is_none() && fx.params.get("drawHiddenFaces").is_none()); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 5e49e4d9ea..5016eacd06 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -24,11 +24,7 @@ pub(crate) fn lighting(d: &Dialog) -> bool { /// surface? fn lit(d: &Dialog) -> bool { let id = d.str("__effect"); - if id == vectorcraft_effects::CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE { - matches!(d.str("surface").as_str(), "diffuse" | "plastic") - } else { - d.bool("shade") - } + if vectorcraft_effects::is_classic_three_d(&id) { matches!(d.str("surface").as_str(), "diffuse" | "plastic") } else { d.bool("shade") } } pub(crate) fn hint(d: &Dialog) -> &'static str { diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index 6c03163f05..a3857c914a 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,11 +1,12 @@ -//! The own controls of Extrude & Bevel (Classic) and Revolve (Classic) in the compact 3D dialog: the -//! extrude and bevel options, or the revolve options, on the Object tab, and the surface with its -//! lights, highlight, blend steps and shading color on the Lighting tab. Position (presets, -//! rotation, perspective) is the Rotation tab every 3D effect shares. +//! The own controls of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) in the +//! compact 3D dialog: the extrude and bevel options, or the revolve options, on the Object tab +//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps and shading +//! color on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D +//! effect shares. use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE}; use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; use crate::state::Dialog; @@ -40,10 +41,13 @@ pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { } /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only), Blend Steps and Shading Color; Draw Hidden Faces last, for Extrude & Bevel (Classic) -/// (Revolve (Classic) draws every face). +/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps and Shading Color; +/// Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and +/// Rotate (Classic)'s plane is seen from either side). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { - choice(ui, d, "surface", tl!("Surface"), &options("surface")); + let id = d.str("__effect"); + let effect = if id == CLASSIC_ROTATE { CLASSIC_ROTATE } else { CLASSIC_EXTRUDE }; + choice(ui, d, "surface", tl!("Surface"), &options_of(effect, "surface")); let surface = d.str("surface"); ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| { light_list(ui, d); @@ -57,24 +61,31 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { store_selected(d, &selected); } number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None); - ui.add_enabled_ui(surface == "plastic", |ui| { - number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); - number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); - }); + if id != CLASSIC_ROTATE { + ui.add_enabled_ui(surface == "plastic", |ui| { + number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); + }); + } number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); - choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor")); + choice(ui, d, "shadingColor", tl!("Shading Color"), &options_of(effect, "shadingColor")); if d.str("shadingColor") == "custom" { shading_custom(ui, d); } }); - if d.str("__effect") == CLASSIC_EXTRUDE { + if id == CLASSIC_EXTRUDE { toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); } } /// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). fn options(key: &str) -> Vec<(Value, &'static str)> { - super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() + options_of(CLASSIC_EXTRUDE, key) +} + +/// The values of classic 3D effect `effect`'s `key` as the dropdowns list them, (value, label). +fn options_of(effect: &str, key: &str) -> Vec<(Value, &'static str)> { + super::effect::own_choices(effect, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() } /// The custom shading color: a color button, its hex value and its reset dot. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9e1883d94a..73159ade11 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1120,6 +1120,7 @@ font-technology Variable Променливи 3D (Classic) 3D (класически) Extrude & Bevel (Classic)… Избутване и фасетка (класически)… Revolve (Classic)… Завъртане (класическо)… + Rotate (Classic)… Ротация (класическа)… Surface Повърхност Wireframe Телена рамка No Shading Без засенчване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9c769fbf79..fe68485770 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -484,6 +484,7 @@ 3D (Classic) 3D (klasično) Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… Revolve (Classic)… Rotaciono tijelo (klasično)… + Rotate (Classic)… Rotiraj (klasično)… Surface Površina Wireframe Žičani model No Shading Bez sjenčenja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3b36368c64..a632b5548b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1574,6 +1574,7 @@ antiAlias Sharp Nítid 3D (Classic) 3D (clàssic) Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… Revolve (Classic)… Revoluciona (clàssic)… + Rotate (Classic)… Gira (clàssic)… Surface Superfície Wireframe Filferro No Shading Sense ombra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 03aaedff30..1e330b65f8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -493,6 +493,7 @@ 3D (Classic) 3D (klasické) Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… Revolve (Classic)… Rotační těleso (klasické)… + Rotate (Classic)… Otočit (klasické)… Surface Povrch Wireframe Drátový model No Shading Bez stínování diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 9515a8a066..6184bfd049 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -451,6 +451,7 @@ 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… Revolve (Classic)… Omdrej (klassisk)… + Rotate (Classic)… Rotér (klassisk)… Surface Overflade Wireframe Trådmodel No Shading Ingen skyggelægning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 411f6fc618..b8c3a7bcb4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1125,6 +1125,7 @@ font-technology Variable Variabel 3D (Classic) 3D (klassisch) Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… Revolve (Classic)… Kreiseln (klassisch)… + Rotate (Classic)… Drehen (klassisch)… Surface Oberfläche Wireframe Drahtmodell No Shading Keine Schattierung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 698f7d985f..d9e9dfaeb4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1098,6 +1098,7 @@ font-technology Variable Μεταβλητές 3D (Classic) 3D (κλασικό) Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… Revolve (Classic)… Περιστροφή γύρω από άξονα (κλασικό)… + Rotate (Classic)… Περιστροφή (κλασικό)… Surface Επιφάνεια Wireframe Συρματόπλεγμα No Shading Χωρίς σκίαση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e3432f8a83..260ba55a99 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1154,6 +1154,7 @@ font-technology Variable Variables 3D (Classic) 3D (clásico) Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… Revolve (Classic)… Revolución (clásico)… + Rotate (Classic)… Rotar (clásico)… Surface Superficie Wireframe Malla No Shading Sin sombreado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 6e05cfadb2..efcbc2dfa3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -490,6 +490,7 @@ 3D (Classic) 3D (perinteinen) Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… Revolve (Classic)… Pyöräytä (perinteinen)… + Rotate (Classic)… Kierrä (perinteinen)… Surface Pinta Wireframe Rautalanka No Shading Ei varjostusta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 89bc73664d..dbbaf6bc64 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1149,6 +1149,7 @@ font-technology Variable Variables 3D (Classic) 3D (classique) Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… Revolve (Classic)… Révolution (classique)… + Rotate (Classic)… Rotation (classique)… Surface Surface Wireframe Fil de fer No Shading Sans ombrage diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 96819e6e4c..42908e32e1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1746,6 +1746,7 @@ antiAlias Sharp शार्प 3D (Classic) 3D (क्लासिक) Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… Revolve (Classic)… रिवॉल्व (क्लासिक)… + Rotate (Classic)… घुमाव (क्लासिक)… Surface सतह Wireframe वायरफ़्रेम No Shading कोई शेडिंग नहीं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2a33bbe884..cae094bc2b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1445,6 +1445,7 @@ font-technology Variable Változó 3D (Classic) 3D (klasszikus) Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… Revolve (Classic)… Forgatás tengely körül (klasszikus)… + Rotate (Classic)… Elforgatás (klasszikus)… Surface Felület Wireframe Drótváz No Shading Nincs árnyékolás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 150904d2b5..9ffa8a82cb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -451,6 +451,7 @@ 3D (Classic) 3D (klasik) Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… Revolve (Classic)… Revolusi (klasik)… + Rotate (Classic)… Putar (klasik)… Surface Permukaan Wireframe Kerangka kawat No Shading Tanpa pengarsiran diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 59d39342b2..cae8c70a75 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1155,6 +1155,7 @@ font-technology Variable Variabili 3D (Classic) 3D (classico) Extrude & Bevel (Classic)… Estrusione e smusso (classico)… Revolve (Classic)… Rivoluzione (classico)… + Rotate (Classic)… Ruota (classico)… Surface Superficie Wireframe Reticolo No Shading Nessuna ombreggiatura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 34bf40c32e..c3de03783a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -528,6 +528,7 @@ digits Malayalam マラヤーラム数字 3D (Classic) 3D(クラシック) Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… Revolve (Classic)… 回転体(クラシック)… + Rotate (Classic)… 回転(クラシック)… Surface 表面 Wireframe ワイヤーフレーム No Shading 陰影なし diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index d302a12c2a..bbf5de74a9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -419,6 +419,7 @@ 3D (Classic) 3D (კლასიკური) Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… Revolve (Classic)… ბრუნვა (კლასიკური)… + Rotate (Classic)… შემობრუნება (კლასიკური)… Surface ზედაპირი Wireframe მავთულის ჩარჩო No Shading დაჩრდილვის გარეშე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8e4bf10b61..ddb5f4917e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -521,6 +521,7 @@ digits Malayalam 말라얄람어 3D (Classic) 3D(클래식) Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… Revolve (Classic)… 회전체(클래식)… + Rotate (Classic)… 회전(클래식)… Surface 표면 Wireframe 와이어프레임 No Shading 셰이딩 없음 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c2cc31699c..4aad90260f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -465,6 +465,7 @@ 3D (Classic) 3D (klassiek) Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… Revolve (Classic)… Draaien (klassiek)… + Rotate (Classic)… Roteren (klassiek)… Surface Oppervlak Wireframe Draadmodel No Shading Geen arcering diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 62c09cf989..fd0b820eeb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -452,6 +452,7 @@ 3D (Classic) 3D (klasyczne) Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… Revolve (Classic)… Obrót (klasyczny)… + Rotate (Classic)… Obróć (klasyczny)… Surface Powierzchnia Wireframe Szkielet No Shading Bez cieniowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1f482116ad..0b24aaf315 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1047,6 +1047,7 @@ antiAlias Sharp Nítido 3D (Classic) 3D (Clássico) Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… Revolve (Classic)… Revolver (Clássico)… + Rotate (Classic)… Girar (Clássico)… Surface Superfície Wireframe Aramado No Shading Sem Sombreamento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3d39ab221a..40c6970203 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1152,6 +1152,7 @@ font-technology Variable Вариативные 3D (Classic) 3D (классические) Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… Revolve (Classic)… Вращение (классическое)… + Rotate (Classic)… Поворот (классический)… Surface Поверхность Wireframe Каркас No Shading Без затенения diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index bc1f07f411..83e526aad9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1133,6 +1133,7 @@ font-technology Variable Variabla 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… Revolve (Classic)… Rotera runt axel (klassisk)… + Rotate (Classic)… Rotera (klassisk)… Surface Yta Wireframe Trådram No Shading Ingen skuggning diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4a6d628616..fbddb6e9f3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1125,6 +1125,7 @@ font-technology Variable Değişken 3D (Classic) 3D (Klasik) Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… Revolve (Classic)… Döndür (Klasik)… + Rotate (Classic)… Çevir (Klasik)… Surface Yüzey Wireframe Tel Kafes No Shading Gölgeleme Yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 41449c01a1..a250597245 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1149,6 +1149,7 @@ font-technology Variable Варіативні 3D (Classic) 3D (класичні) Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… Revolve (Classic)… Обертання (класичне)… + Rotate (Classic)… Поворот (класичний)… Surface Поверхня Wireframe Каркас No Shading Без затінення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bc517d6f40..b44405514d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1322,6 +1322,7 @@ font-technology Variable Biến thiên 3D (Classic) 3D (cổ điển) Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… Revolve (Classic)… Xoay tròn (cổ điển)… + Rotate (Classic)… Xoay (cổ điển)… Surface Bề mặt Wireframe Khung dây No Shading Không tô bóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5f40262540..980c11056e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1095,6 +1095,7 @@ antiAlias Sharp 锐利 3D (Classic) 3D (经典) Extrude & Bevel (Classic)… 突出与斜角 (经典)… Revolve (Classic)… 回转 (经典)… + Rotate (Classic)… 旋转 (经典)… Surface 表面 Wireframe 线框 No Shading 无底纹 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 83f374090a..0637c0baca 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1119,6 +1119,7 @@ font-technology Variable 可變 3D (Classic) 3D (經典) Extrude & Bevel (Classic)… 突出與斜角 (經典)… Revolve (Classic)… 迴轉 (經典)… + Rotate (Classic)… 旋轉 (經典)… Surface 表面 Wireframe 線框 No Shading 無網底 diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index ae7e4829b0..e49f4788ea 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -540,6 +540,22 @@ fn classic_revolve_is_in_the_3d_classic_submenu() { assert!(at("Extrude & Bevel (Classic)…").unwrap() < at("Revolve (Classic)…").unwrap()); } +/// Effect › 3D and Materials › 3D (Classic) › Rotate (Classic)… opens its effect dialog, after +/// Revolve (Classic)…, and nowhere else. +#[test] +fn classic_rotate_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicRotate")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Rotate (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Revolve (Classic)…").unwrap() < at("Rotate (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 49bb03781c..568eaa75e5 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,13 +181,13 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) and Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, the rest of 3D Classic (Rotate, Map Art), Turntable) is 25–45 h. +angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) is 25–45 h. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 7a8c001351..11031acc19 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -663,7 +663,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D - Classic (Rotate, Map Art), Turntable, textures, map art and shading in 3D export. + Classic (Map Art), Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. diff --git a/docs/mcp.md b/docs/mcp.md index 3e2b9e9358..4c6340a5a2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3078,8 +3078,8 @@ See [Revolve](revolve.md) for the scope and limitations. replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel -(Classic) and Revolve (Classic) have no lighting presets: a target with either at `index` is an error, nothing -changes, and `effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. +(Classic), Revolve (Classic) and Rotate (Classic) have no lighting presets: a target with any of them at `index` is an +error, nothing changes, and `effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height @@ -3111,7 +3111,7 @@ art", and a loaded file draws the source path, as it does for a solid past its l documentation names the controls and their ranges ([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)). The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps -it as a path. Extrude & Bevel (Classic) and Revolve (Classic) have no shadow. See [three-d.md](three-d.md#shadows). +it as a path. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. See [three-d.md](three-d.md#shadows). ```json {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}} @@ -3209,6 +3209,29 @@ changed. {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRevolve","item":null,"params":{"angle":270,"offset":10,"surface":"diffuse","blendSteps":64}}}} ``` +## Rotate (Classic) + +`effect.apply {effect: "threeD.classicRotate", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Rotate (Classic)…. It turns every fill and stroke as one flat plane about the center of the object's +bounds, as `threeD.rotate` does, each in its own paint, and draws the plane from either side. Parameters: +`rotationX/Y/Z` and `perspectiveAngle` as above; `surface` (`diffuse`, the default, lights the art softly, and +`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor` and `shadingCustom` as Extrude & +Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group +or layer, whose members turn about one shared center, or on a fill or stroke. The public documentation describes the +dialog's Position and Surface settings +([Rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)), +the Position presets, rotations from -180 to 180 and Perspective from 0 to 160 +([Set 3D rotation position options](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=270)), +and No Shading and Diffuse Shading as the only surfaces for an object that uses only this effect +([Surface shading options](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=271)). +The defaults and the shading are VectorCraft's own. An unknown parameter (`highlightSize` and `drawHiddenFaces` +included), a number out of range, a flag that is not true or false, a surface other than `none` or `diffuse`, or +another shading color is refused with an error and the document is not changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRotate","item":null,"params":{"rotationX":0,"rotationY":40,"rotationZ":0,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork diff --git a/docs/three-d.md b/docs/three-d.md index 5fe3d2a89b..a79e6a4900 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -141,7 +141,7 @@ them all. The members stay editable: moving or editing one reshapes the solids. ## Clipping groups, blends and symbol instances -Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate, Revolve and Revolve (Classic)) also applies to a clipping +Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate, Rotate (Classic), Revolve and Revolve (Classic)) also applies to a clipping group, a blend or a symbol instance, alone or inside a group or layer. The help page says that effects apply to objects, groups or layers ([Apply and modify effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html)), that a clipping mask hides everything outside its shape ([Clipping masks](https://helpx.adobe.com/illustrator/using/clipping-masks.html)), @@ -279,7 +279,7 @@ Our reading: command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded file draws the source path. - Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a - path below the faces. Extrude & Bevel (Classic) and Revolve (Classic) have no shadow. + path below the faces. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. ## Parameters (`effect.apply`, `effect.setParams`) @@ -336,9 +336,10 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a circle builds. -- **3D (Classic)**: Extrude & Bevel (Classic) and Revolve (Classic) are built, with their surface types and blend - steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws every face back - to front, so it has no Draw Hidden Faces setting. Rotate (Classic), the track cube, map art, Turntable and 3D export +- **3D (Classic)**: Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) are built, with their surface + types and blend steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws + every face back to front, so it has no Draw Hidden Faces setting. Rotate (Classic) turns the plane Rotate turns, seen + from either side, with the No Shading and Diffuse surfaces only. The track cube, map art, Turntable and 3D export are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). From 05dcab02848317b3467fef419f3423d7d1b4ae11 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:46:07 -0800 Subject: [PATCH 109/126] Data Recovery: another app leaves a copy alone until its app writes it again When a running app has to leave its recovery area (its lock file was removed and another app locked the area), its copies stay in the old area until it writes them again in its new area. Only that app knew this, so once the other app let the old area go, any app could offer, restore or discard those copies while their app still ran. The app now writes an entry `<new area>/keeps-<old area>` in its own area while copies of its documents are left in the old one, and removes it when none are. Another app that finds an area unlocked checks for such an entry, and treats the area as a running app's while the area that names it is held. After a crash the new area is no longer held, so the copies are offered as before. The heartbeat writes the entry again if it was removed. --- crates/engine/src/cmd/recovery.rs | 95 ++++++++++++++++++++++++++++- crates/engine/src/tests/recovery.rs | 48 +++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 3424da3ddd..db2d1ecd4e 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -19,7 +19,9 @@ //! while their copies are restored or discarded the area is held, so two apps launched together //! never both take it. An area's lock file or folder can be removed while its app runs, so each //! recovery save and heartbeat checks that the lock still holds the area, and locks it again or -//! takes a new area. +//! takes a new area. The copies it had in the old area stay there until it has written them again +//! in the new one, and an entry `<new area>/keeps-<old area>` ([`mark_left`]) tells other apps +//! that those copies belong to a running app, so none of them offers or discards them meanwhile. //! //! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so //! when unsure an area counts as held, and its app repairs what another app did meanwhile: each @@ -61,6 +63,9 @@ const BLOB_EXT: &str = ".blob"; /// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix /// seconds, as text). const BEAT: &str = "heartbeat"; +/// The start of an entry `<area>/keeps-<left area>`: the app holding `<area>` still keeps the copies +/// it left in `<left area>` ([`mark_left`]). +const KEEPS: &str = "keeps-"; /// The least time without a heartbeat after which an area's app counts as gone (seconds); else /// three Data Recovery intervals. const MIN_STALE: i64 = 180; @@ -479,6 +484,8 @@ pub struct Recovery { /// store: each stays until its document has a copy in the new area, or needs none. They are /// still this session's: never offered or discarded as a crash's here. left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>, + /// The `<own area>/keeps-<left area>` entries this session wrote in its area ([`mark_left`]). + marked: BTreeSet<String>, } impl Recovery { @@ -553,6 +560,15 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>) /// beat only lets another app offer the copies once three intervals have passed. pub fn heartbeat(s: &mut Session) -> Vec<u64> { let mut lost = keep_hold(s); + // An entry that couldn't be written before, or that was removed since (by the user, a + // cleaner), is written now. + if let Some(own) = &s.recovery.own + && !s.recovery.marked.is_empty() + && let Ok(names) = own.store.list() + { + s.recovery.marked.retain(|n| names.contains(n)); + } + mark_left(s); if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) { let _ = beat(own.store.as_ref(), &own.area); } @@ -622,6 +638,36 @@ fn drop_left(s: &mut Session, uid: u64) { let meta = store.remove(&format!("{file}{META_EXT}")); if doc.is_ok() && meta.is_ok() { s.recovery.left.remove(&uid); + mark_left(s); + } +} + +/// Record in this session's area each area it left that still holds copies of its documents: an +/// entry `<own area>/keeps-<left area>`, so another app leaves those copies alone while this one +/// runs ([`keeper`]). The entries of areas that hold none of its copies any more go. Best effort: +/// an entry not written or removed now is written or removed at the next heartbeat. +fn mark_left(s: &mut Session) { + let Some(own) = &s.recovery.own else { return }; + let (store, area) = (own.store.clone(), own.area.clone()); + let want: BTreeSet<String> = s + .recovery + .left + .values() + .filter(|(st, _)| st.location() == store.location()) + .filter_map(|(_, f)| f.split_once('/').map(|(a, _)| a)) + .filter(|a| *a != area) + .map(|a| format!("{area}/{KEEPS}{a}")) + .collect(); + let gone: Vec<String> = s.recovery.marked.difference(&want).cloned().collect(); + for n in gone { + if store.remove(&n).is_ok() { + s.recovery.marked.remove(&n); + } + } + for n in want { + if !s.recovery.marked.contains(&n) && store.write(&n, b"").is_ok() { + s.recovery.marked.insert(n); + } } } @@ -670,6 +716,9 @@ pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> } }; s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold, beats }); + // The entries written before are in the area this session had (released or left). + s.recovery.marked.clear(); + mark_left(s); return Ok(area); } Err(EngineError::Other("no free recovery area".into())) @@ -686,6 +735,8 @@ pub fn release(s: &mut Session) { drop_left(s, uid); } let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return }; + // Its `keeps-` entries go with the area's other entries. + s.recovery.marked.clear(); let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { let _ = store.remove(name); @@ -931,6 +982,7 @@ impl RecoveryJob { let _ = store.remove(&format!("{file}{DOC_EXT}")); let _ = store.remove(&format!("{file}{META_EXT}")); } + mark_left(s); } // Best effort, as in `remove_copy`. _ => { @@ -1152,6 +1204,10 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } match store.lock(area) { Ok(Lock::Held(h)) => { + // Copies an app left here when it had to leave the area stay its own while it runs. + if let Some(who) = keeper(s, store, area) { + return who; + } hold.push(h); Owner::Gone } @@ -1160,6 +1216,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } + if let Some(who) = keeper(s, store, area) { + return who; + } // Best effort: without it another app might offer the copies too, which loses nothing. if take && let Ok(t) = beat(store, area) { s.recovery.adopted.insert(area.to_string(), t); @@ -1169,6 +1228,40 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } } +/// Who keeps the copies left in `area` by an app that had to leave it, from the entries +/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`: +/// no app that runs keeps them. A store that can't be listed now keeps them (a copy kept loses +/// nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> { + let Ok(names) = store.list() else { return Some(Owner::Running) }; + let mark = format!("/{KEEPS}{area}"); + let mut who = None; + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + match holder(s, store, k) { + Owner::Mine => return Some(Owner::Mine), + Owner::Running => who = Some(Owner::Running), + Owner::Gone => {} + } + } + who +} + +/// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a +/// lock taken to tell goes at once). +fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner { + if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { + return Owner::Mine; + } + match store.lock(area) { + Ok(Lock::Held(_)) => Owner::Gone, + Ok(Lock::Busy) | Err(_) => Owner::Running, + Ok(Lock::Unsupported) => { + let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()); + if runs { Owner::Running } else { Owner::Gone } + } + } +} + /// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The /// area is removed while it is still held, so an app taking a new area of that name meanwhile /// finds it held or gone and takes the next name. diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index f0b6a0708c..84e212c826 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -958,6 +958,54 @@ fn a_copy_left_behind_is_not_offered_by_its_own_app() { let _ = std::fs::remove_dir_all(&dir); } +/// A copy left in an area the session had to leave is not another app's to offer, restore or +/// discard while the session runs, even after the app that locked that area let it go: an entry in +/// the session's new area says it keeps it. Once the copy is written again in the new area, the +/// entry goes; after a crash before that, the copy is offered. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_is_not_offered_by_another_app_while_its_app_runs() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftother-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let left_behind = |area: &str| dir.join(area).join("Untitled-1-1.vectorcraft"); + let leave = |a: &mut Session, area: &str| { + std::fs::remove_file(dir.join(area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(a); + drop(other); + a.recovery.own_area().unwrap().to_string() + }; + let (mut a, area) = saved_in(&dir); + let mine = leave(&mut a, &area); + assert_ne!(mine, area); + assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file()); + // Removed meanwhile: the next heartbeat writes it again. + std::fs::remove_file(dir.join(&mine).join(format!("keeps-{area}"))).unwrap(); + cmd::recovery::heartbeat(&mut a); + assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file()); + let mut b = other_app(&dir); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(b.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err()); + assert!(left_behind(&area).is_file()); + // Written again in the new area: the copy left behind and the entry go. + a.execute("file.recovery.save", &json!({})).unwrap(); + assert!(!left_behind(&area).exists()); + assert!(!dir.join(&mine).join(format!("keeps-{area}")).exists()); + assert!(offered(&mut b).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); + // A crash before the copy is written again: the copy left behind is offered. + let (mut a, area) = saved_in(&dir); + leave(&mut a, &area); + drop(a); + assert_eq!(offered(&mut other_app(&dir)).len(), 1); + assert!(left_behind(&area).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + /// A complex document (Data Recovery off for those) gets no new copy after the session left its /// area: it keeps the copy it left there, and the heartbeat asks for it only once. #[cfg(unix)] From aec586b8403833a1de70115dc03da95ff874f474 Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Thu, 12 Nov 2026 12:51:06 +0500 Subject: [PATCH 110/126] Blend: moving a key of a blend with a replaced spine moves it (#6809) After Replace Spine the keys are not pinned to spine anchors, so the steps spread them evenly along the spine by arc length and put a moved key straight back where it was. Once a key is moved off its place, the spine is laid out pinned at the anchors where the keys were spread, and the moved key takes its point of the spine along, as on a pinned spine. --- crates/doc/src/blend.rs | 55 ++++++++++++++++++++++++++++----- crates/engine/src/tests/live.rs | 31 +++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 05968ced7e..9845bab8ad 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1051,7 +1051,8 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option<Vec<usize>> { /// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread -/// evenly along it by arc length). +/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as +/// above at the anchors where they were spread. /// /// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> { @@ -1077,7 +1078,23 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; let mut first = stored.subpaths.first()?.clone(); - let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) }; + let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { + let mut path = PathData::single(first); + // A key moved off where Replace Spine spread it takes its point of the spine along, as + // on a pinned spine; otherwise the steps would put it back on the spine (#6809). + if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) { + return Some((path, None)); + } + let Some(ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) }; + let mut first = path.subpaths.into_iter().next()?; + for (k, &ai) in keys.iter().zip(&ka) { + if let Some(a) = first.anchors.get_mut(ai) { + let d = center_of(k) - a.p; + a.translate(d); + } + } + return Some((PathData::single(first), Some(ka))); + }; for (k, &ai) in keys.iter().zip(&ka) { let c = center_of(k); if let Some(a) = first.anchors.get_mut(ai) { @@ -1091,21 +1108,43 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio /// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its /// keys sit (by arc length), so editing it keeps them in place. pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<usize>)> { - let (mut path, anchors) = blend_spine(keys, spec)?; - if let Some(a) = anchors { - return Some((path, a)); + // A spine from Replace Spine is pinned as stored, even with a key moved off it (where + // [`blend_spine`] moves that key's point along): the layout is the same. + if keys.len() >= 2 + && let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first()) + && pinned(spec, keys.len(), first.anchors.len()).is_none() + { + let mut path = PathData::single(first.clone()); + let out = spread_anchors(&mut path, keys.len())?; + return Some((path, out)); } + let (path, anchors) = blend_spine(keys, spec)?; + Some((path, anchors?)) +} + +/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length +/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)? +fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool { let k = keys.len(); - let total = Spine::new(&path)?.length(); + keys.iter().enumerate().all(|(i, key)| { + let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; + center_of(key).distance(spine.at(f).0) < 0.01 + }) +} + +/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit +/// on, splitting segments where none is. +fn spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> { + let total = Spine::new(path)?.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); let mut out = vec![0usize]; for i in 1..k.saturating_sub(1) { let s = total * i as f64 / (k - 1) as f64; - out.push(anchor_at_length(&mut path, s)?); + out.push(anchor_at_length(path, s)?); } let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); out.push(last); - Some((path, out)) + Some(out) } /// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 9fc790efa2..597e63c3f3 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -424,6 +424,37 @@ fn blend_replace_spine_follows_path() { assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}"); } +/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine, +/// not only the key's selection outline. +#[test] +fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap()); + sel(&mut s, &[g, arc]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + // Direct Selection picks the key inside the blend; the drag moves it. + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}"); + // Undo puts the key back on the spine as Replace Spine left it. + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + #[test] fn blend_align_to_path_rotates_steps() { let mut s = session(); From 94c8e472c11196e571ced19328671141e247340d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:56:57 -0800 Subject: [PATCH 111/126] Plug-ins: refreshing deferred effects no longer takes time in the square of the number of stages Effect > Refresh Deferred Effects looked up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran with Vec::contains, and counted each target stage by scanning every deferred record. Asking whether a stage stays deferred and replacing a stage's records when it runs also scanned every record. Deferrals::refresh now keeps the objects asked for and the objects drawn whole in sets, and Refresh's ids, retried, visited and touched and Pass.ran and seen are sets. The deferred records are kept in a BTreeMap by the order they were made in, with an index by stage and input key, so holds, State::remove, settle and the refresh's reconciliation and count look at the records of the stages concerned only. The targets, counts, records kept and their order, marks and revision are unchanged. Docs: docs/target-app-parity.md. --- crates/plugins/src/deferred.rs | 328 +++++++++++++++++++++++++++------ docs/target-app-parity.md | 1 + 2 files changed, 269 insertions(+), 60 deletions(-) diff --git a/crates/plugins/src/deferred.rs b/crates/plugins/src/deferred.rs index 1645dd55e0..a29d877d44 100644 --- a/crates/plugins/src/deferred.rs +++ b/crates/plugins/src/deferred.rs @@ -19,7 +19,7 @@ //! [`revision`] changes, so that drawings made with the deferred stage are drawn again. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -106,9 +106,108 @@ enum Phase { Open, } +/// The deferred records in the order they were made, indexed by stage and cache key. A stage holds +/// at most one record for each key. +#[derive(Default)] +struct Records { + /// The order number of the next record. + next: u64, + by_order: BTreeMap<u64, Deferred>, + /// For each stage with records, the order number of its record for each key. + by_stage: HashMap<Stage, HashMap<u64, u64>>, +} + +impl Records { + /// Every record, oldest first. + fn iter(&self) -> impl Iterator<Item = &Deferred> { + self.by_order.values().inspect(|_| looked_at(1)) + } + + /// The stages with records, in no particular order. + fn stages(&self) -> impl Iterator<Item = Stage> + '_ { + self.by_stage.keys().copied() + } + + /// The records of `stage`, in no particular order. + fn of(&self, stage: Stage) -> impl Iterator<Item = &Deferred> { + self.by_stage.get(&stage).into_iter().flat_map(|keys| keys.values()).filter_map(|n| { + looked_at(1); + self.by_order.get(n) + }) + } + + /// Is `stage` recorded with cache key `key`? + fn contains(&self, stage: Stage, key: u64) -> bool { + looked_at(1); + self.by_stage.get(&stage).is_some_and(|keys| keys.contains_key(&key)) + } + + /// Adds `d` as the newest record, in place of the record of its stage and key. + fn push(&mut self, d: Deferred) { + let order = self.next; + self.next = self.next.wrapping_add(1); + if let Some(old) = self.by_stage.entry(d.stage).or_default().insert(d.key, order) + && let Some(old) = self.by_order.remove(&old) + { + unmark(old.key); + } + self.by_order.insert(order, d); + } + + /// Drops the records of `stage` whose key `drop` gives true for. + fn drop_where(&mut self, stage: Stage, drop: impl Fn(u64) -> bool) { + let Some(keys) = self.by_stage.get_mut(&stage) else { return }; + looked_at(keys.len()); + let by_order = &mut self.by_order; + keys.retain(|&key, order| { + let keep = !drop(key); + if !keep && let Some(d) = by_order.remove(order) { + unmark(d.key); + } + keep + }); + if keys.is_empty() { + self.by_stage.remove(&stage); + } + } + + /// Drops the records for which `keep` is false. + fn retain(&mut self, mut keep: impl FnMut(&Deferred) -> bool) { + let by_stage = &mut self.by_stage; + self.by_order.retain(|_, d| { + looked_at(1); + let k = keep(d); + if !k { + if let Some(keys) = by_stage.get_mut(&d.stage) { + keys.remove(&d.key); + if keys.is_empty() { + by_stage.remove(&d.stage); + } + } + unmark(d.key); + } + k + }); + } +} + +#[cfg(test)] +thread_local! { + /// How many records the lookups on this thread have looked at. + static LOOKED_AT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` records looked at, in tests. +fn looked_at(n: usize) { + #[cfg(test)] + LOOKED_AT.with(|c| c.set(c.get() + n)); + #[cfg(not(test))] + let _ = n; +} + struct State { phase: Phase, - deferred: Vec<Deferred>, + deferred: Records, /// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it. report: Option<usize>, /// The refresh running, if one is ([`Deferrals::refresh`]). @@ -123,13 +222,13 @@ struct Refresh { /// When its budget started (`None`: no run yet). started: Option<u64>, /// The objects whose stages run again; the others' stay deferred. - ids: Vec<u64>, + ids: HashSet<u64>, /// The cache keys whose cached failures have been run again. - retried: Vec<u64>, + retried: HashSet<u64>, /// The stages of `ids` that were drawn. - visited: Vec<Stage>, + visited: HashSet<Stage>, /// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again. - touched: Vec<(Stage, u64)>, + touched: HashSet<(Stage, u64)>, } impl State { @@ -155,7 +254,7 @@ impl State { impl Drop for State { fn drop(&mut self) { - for d in &self.deferred { + for d in self.deferred.iter() { unmark(d.key); } } @@ -197,7 +296,7 @@ impl Deferrals { pub fn with_clock(clock: Clock) -> Self { Self(Arc::new(Mutex::new(State { phase: Phase::Budgeted { started: None, first: true }, - deferred: Vec::new(), + deferred: Records::default(), report: None, refresh: None, clock, @@ -211,7 +310,7 @@ impl Deferrals { /// The deferred stages, by object and effect, each once (a stage drawn with several inputs, /// such as the paths of a group, is listed with the first one's reason). pub fn list(&self) -> Vec<Deferred> { - let mut out = self.state().deferred.clone(); + let mut out: Vec<Deferred> = self.state().deferred.iter().cloned().collect(); out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect)); out.dedup_by_key(|d| d.stage); out @@ -238,13 +337,10 @@ impl Deferrals { if pass.ran.is_empty() { return; } - self.state().deferred.retain(|d| { - let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key)); - if !keep { - unmark(d.key); - } - keep - }); + let mut s = self.state(); + for &stage in &pass.ran { + s.deferred.drop_where(stage, |key| !pass.seen.contains(&(stage, key))); + } } /// The first view has been drawn: later drawings leave deferred stages deferred, and @@ -271,14 +367,14 @@ impl Deferrals { pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec<u64>) -> Refreshed { let (targets, ids) = { let mut s = self.state(); - let mut targets: Vec<Stage> = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect(); + let asked: Option<HashSet<u64>> = objects.map(|o| o.iter().copied().collect()); + let mut targets: Vec<Stage> = s.deferred.stages().filter(|t| asked.as_ref().is_none_or(|o| o.contains(&t.object))).collect(); targets.sort_by_key(|t| (t.object, t.item, t.effect)); - targets.dedup(); let mut ids: Vec<u64> = targets.iter().map(|t| t.object).collect(); ids.dedup(); let thread = std::thread::current().id(); - let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new()); - s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched }); + let (retried, visited, touched) = (HashSet::new(), HashSet::new(), HashSet::new()); + s.refresh = Some(Refresh { thread, started: None, ids: ids.iter().copied().collect(), retried, visited, touched }); (targets, ids) }; // The refresh ends when `run` returns, also when it unwinds. @@ -289,20 +385,16 @@ impl Deferrals { } } let reopen = Reopen(self); - let whole = run(&ids); + let whole: HashSet<u64> = run(&ids).into_iter().collect(); let mut s = self.state(); let touched = match s.refresh.take() { Some(r) => { - s.deferred.retain(|d| { - let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key)); - if gone { - unmark(d.key); - } - !gone - }); + for &stage in r.visited.iter().filter(|t| whole.contains(&t.object)) { + s.deferred.drop_where(stage, |key| !r.touched.contains(&(stage, key))); + } r.touched } - None => Vec::new(), + None => HashSet::new(), }; drop(s); drop(reopen); @@ -310,11 +402,11 @@ impl Deferrals { // than time; one with records left that this refresh did not run is still deferred. let s = self.state(); let mut out = Refreshed::default(); - for t in &targets { - let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable(); + for &t in &targets { + let mut left = s.deferred.of(t).peekable(); if left.peek().is_none() { out.refreshed += 1; - } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) { + } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(t, d.key))) { out.failed += 1; } else { out.still_deferred += 1; @@ -355,7 +447,7 @@ impl Deferrals { pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool { let mut s = self.state(); let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false); - let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key); + let held = settled && s.deferred.contains(stage, key); drop(s); if held { seen(stage, key); @@ -367,30 +459,22 @@ impl Deferrals { /// reached the stage. pub(crate) fn visit(&self, stage: Stage) { let mut s = self.state(); - if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) { - r.visited.push(stage); + if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object)) { + r.visited.insert(stage); } } /// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is /// false, such as those of effects or objects removed from the document. pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) { - self.state().deferred.retain(|d| { - let k = keep(d.stage, &d.plugin); - if !k { - unmark(d.key); - } - k - }); + self.state().deferred.retain(|d| keep(d.stage, &d.plugin)); } /// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key /// while a refresh running here runs the stages of `stage`'s object. pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool { let mut s = self.state(); - let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false }; - r.retried.push(key); - true + s.refresh_here().filter(|r| r.ids.contains(&stage.object)).is_some_and(|r| r.retried.insert(key)) } /// What a run starting now may take of the budget: the budget starts with the first run. @@ -425,13 +509,7 @@ impl State { /// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0 /// removes every record of the stage. fn remove(&mut self, stage: Stage, key: u64) { - self.deferred.retain(|d| { - let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0); - if !keep { - unmark(d.key); - } - keep - }); + self.deferred.drop_where(stage, |k| k == key || k == 0 || key == 0); } /// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this @@ -439,12 +517,12 @@ impl State { /// view [`Deferrals::settle`] looks at the stage when that drawing ends. fn ran_here(&mut self, stage: Stage, key: u64) { if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) { - r.touched.push((stage, key)); + r.touched.insert((stage, key)); } if self.phase_here() == Phase::Open { let _ = PASSES.try_with(|p| { - if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) { - pass.ran.push(stage); + if let Some(pass) = p.borrow_mut().last_mut() { + pass.ran.insert(stage); } }); } @@ -452,9 +530,8 @@ impl State { /// The deferred stages, each once. fn stages(&self) -> Vec<Stage> { - let mut out: Vec<Stage> = self.deferred.iter().map(|d| d.stage).collect(); + let mut out: Vec<Stage> = self.deferred.stages().collect(); out.sort_by_key(|t| (t.object, t.item, t.effect)); - out.dedup(); out } } @@ -469,15 +546,15 @@ thread_local! { /// deferred again), and the stage inputs it left deferred. #[derive(Default)] struct Pass { - ran: Vec<Stage>, - seen: Vec<(Stage, u64)>, + ran: HashSet<Stage>, + seen: HashSet<(Stage, u64)>, } /// The drawing on this thread left `stage` deferred with cache key `key`. fn seen(stage: Stage, key: u64) { let _ = PASSES.try_with(|p| { if let Some(pass) = p.borrow_mut().last_mut() { - pass.seen.push((stage, key)); + pass.seen.insert((stage, key)); } }); } @@ -676,6 +753,79 @@ mod tests { assert_eq!(d.list().iter().map(|x| x.stage).collect::<Vec<_>>(), [st(2, 0), st(3, 0)]); } + /// Objects asked for or drawn whole more than once count once; a stage with several inputs + /// gives a result when its object is drawn whole, fails when an input it ran with fails again + /// (even beside an older failure it did not run), and stays deferred when it is reached but + /// not drawn whole or not reached at all. + #[test] + fn a_refresh_with_repeated_objects_and_several_inputs_per_stage() { + const K: u64 = 0xdef1_0000; + let marked = |key| MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key)); + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let st = |object| Stage { object, effect: 0, item: None }; + for (object, reason, key) in [ + (10, Reason::Budget, K + 1), + (10, Reason::Budget, K + 2), + (11, Reason::Timeout, K + 3), + (11, Reason::Budget, K + 4), + (12, Reason::Timeout, K + 5), + (13, Reason::Budget, K + 6), + (14, Reason::Budget, K + 7), + ] { + d.defer(st(object), "a", reason, key); + } + let before = revision(); + let seen = std::cell::RefCell::new(vec![]); + let counts = d.refresh(Some(&[10, 11, 12, 13, 10, 11, 99]), |ids| { + seen.borrow_mut().extend_from_slice(ids); + d.visit(st(10)); + d.visit(st(10)); + d.ran(st(10), K + 1); + d.visit(st(11)); + d.defer(st(11), "a", Reason::Failed, K + 8); + d.visit(st(12)); + vec![10, 13, 10, 13] + }); + assert_eq!(*seen.borrow(), [10, 11, 12, 13]); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 1 }); + assert!(revision() > before); + let keys = || d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::<Vec<_>>(); + assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]); + assert!(!marked(K + 1) && !marked(K + 2), "the records of the stage that gave a result"); + assert!(marked(K + 3) && marked(K + 8)); + + // Again, with nothing reached: every stage asked for stays as it was. + let counts = d.refresh(Some(&[11, 12, 12]), |_| vec![11, 12]); + assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 2, failed: 0 }); + assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]); + } + + /// A refresh whose drawing unwinds ends: later drawings on this thread are no longer part of + /// it, and the next refresh has a budget of its own. + #[test] + fn a_refresh_that_unwinds_ends() { + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let s = Stage { object: 5, effect: 0, item: None }; + d.defer(s, "a", Reason::Budget, 0xdef2_0001); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + d.refresh(None, |_| { + assert!(!d.holds(s, 0xdef2_0001)); + panic!("the drawing stopped") + }) + })); + assert!(unwound.is_err()); + assert!(matches!(d.allowance(), Allowance::Unlimited)); + assert!(d.holds(s, 0xdef2_0001), "the stage stays deferred"); + let counts = d.refresh(Some(&[5]), |_| { + assert!(matches!(d.allowance(), Allowance::Until { .. })); + d.visit(s); + vec![5] + }); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 0, failed: 0 }); + } + /// After the first view, a stage drawn with several inputs (the paths of a group) whose one /// changed input runs keeps the records of the inputs the same drawing left deferred, and drops /// the record of the input it no longer has. @@ -724,4 +874,62 @@ mod tests { drop((d, other)); assert!(!marked(), "the document closed"); } + + /// Deferrals after the first view with `n` stages, one input each, keyed from `base`. + fn many(n: u64, base: u64) -> Deferrals { + let d = Deferrals::with_clock(stepping(1)); + for object in 0..n { + d.defer(Stage { object, effect: 0, item: None }, "a", Reason::Budget, base + object); + } + d.first_view_done(); + d + } + + /// How many records `f` looks at on this thread. + fn looked_at_by(f: impl FnOnce()) -> usize { + let before = LOOKED_AT.with(std::cell::Cell::get); + f(); + LOOKED_AT.with(std::cell::Cell::get) - before + } + + /// Asking whether a stage input stays deferred looks at that stage's records only, however + /// many other stages are deferred. + #[test] + fn holding_a_stage_looks_at_its_own_records_only() { + const K: u64 = 0xdef3_0000; + let d = many(2_000, K); + let s = Stage { object: 1_000, effect: 0, item: None }; + let n = looked_at_by(|| { + assert!(d.holds(s, K + 1_000)); + assert!(!d.holds(s, K + 1_001), "another stage's input"); + assert!(!d.holds(Stage { effect: 1, ..s }, K + 1_000), "another stage"); + }); + assert!(n <= 3, "looked at {n} records"); + } + + /// A stage that gives a result, or is deferred again, replaces its own records only, and so + /// does the end of a drawing after the first view: the other stages' records are not looked at. + #[test] + fn replacing_a_stage_record_looks_at_its_own_records_only() { + const K: u64 = 0xdef4_0000; + let d = many(2_000, K); + let st = |object| Stage { object, effect: 0, item: None }; + let n = looked_at_by(|| { + d.ran(st(1_000), K + 1_000); + d.defer(st(1_001), "a", Reason::Failed, K + 9_999); + d.defer(st(1_002), "a", Reason::Missing, 0); + d.ran(st(1_002), 7); + }); + assert!(n <= 6, "looked at {n} records"); + let n = looked_at_by(|| { + d.view(|| { + assert!(!d.holds(st(1_003), K + 9_998)); + d.ran(st(1_003), K + 9_998); + }); + }); + assert!(n <= 4, "looked at {n} records"); + let keys = d.state().deferred.iter().filter(|x| (1_000..1_004).contains(&x.stage.object)).map(|x| x.key).collect::<Vec<_>>(); + assert_eq!(keys, [K + 1_001, K + 9_999]); + assert_eq!(d.list().len(), 1_997); + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..dd52cc2d1a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1714,6 +1714,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings a failed removal can't move back:** when `plugin.remove` can't remove the grant and can't rename the plug-in's `.removed` folder back, its error names that folder, and the next load, install or removal of the plug-in renames it back instead of deleting it. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. From aa8dd68d9fcb7d5a3959331478f928bf6e40fcd0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:59:48 -0800 Subject: [PATCH 112/126] 3D (Classic): a group member in a transparent gradient keeps the shared face order With Revolve (Classic) on a group, a member whose gradient has transparent stops drew its whole surface in the members' order, so it could cover a member in front of it or hide behind one it is in front of. Its faces now take their places in the one back-to-front order of every member's faces, in runs as a member with transparency of its own does, and each run carries the member's opacity mask. --- crates/effects/src/revolve.rs | 15 ++++++---- crates/effects/src/revolve/classic.rs | 43 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e7fd5e5f22..c73d76e2c8 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -741,10 +741,11 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { if surfaces.is_empty() { return None; } - // A classic surface whose gradient has transparent stops carries an opacity mask, and stays whole. - let plain = surfaces.iter().all(|(depths, s, _)| { - s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()) - }); + let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + // A classic surface whose gradient has transparent stops carries an opacity mask: its faces go + // in runs, as a transparent member's do, and each run carries the mask. + let masks: Vec<Option<Box<vectorcraft_doc::OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); + let opaque = opaque && masks.iter().all(Option::is_none); let children = if !plain { scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) } else { @@ -786,7 +787,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); continue; }; - if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { + let masked = masks.get(member).is_some_and(Option::is_some); + if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() && !masked { runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); continue; } @@ -822,6 +824,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { let mut group = Node::group(n.id, r.faces); group.opacity = r.opacity; group.blend = r.blend; + if let Some(mask) = r.member.and_then(|m| masks.get(m)).cloned().flatten() { + group.extras_mut().mask = Some(mask); + } Some((r.scopes, Arc::new(group))) }); scoped(n.id, runs.collect(), 0) diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index c9a73ddd82..9a3d98a380 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -152,6 +152,49 @@ mod tests { assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn"); } + /// A group member in a gradient with transparent stops keeps the shared back-to-front order of + /// every member's faces: its faces go in runs that carry its opacity mask, and the other + /// member's faces are drawn between them where they are in front of some and behind others. + #[test] + fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let art = |end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))); + let mut b = BezPath::new(); + b.move_to((150.0, 60.0)); + b.line_to((170.0, 60.0)); + b.line_to((170.0, 140.0)); + let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(outer), std::sync::Arc::new(profile())]); + g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({})).unwrap()); + crate::revolve_art(&g).unwrap() + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + assert!(!order(&art(1.0)).contains('M'), "an opaque gradient needs no mask"); + let masked = order(&art(0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "faces between the member's runs: {masked}"); + } + #[test] fn bad_parameters_are_refused() { let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p)); From 1d103695c4a3133248660d57524068114e8b15dc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:12:16 -0800 Subject: [PATCH 113/126] Data Recovery: a keeps- entry counts only in an area this account can lock An entry `<area>/keeps-<left area>` kept the copies in the left area whenever the lock check on `<area>` failed, so an entry in a folder of another account, or in one whose lock file can't be opened, kept a crashed app's copies from being offered. Such an entry now keeps nothing: only an area that this session holds, or that a running app of this account has locked, keeps the copies it names. --- crates/engine/src/cmd/recovery.rs | 12 ++-- crates/engine/src/tests/recovery.rs | 88 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index db2d1ecd4e..a2395c744c 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -1230,8 +1230,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< /// Who keeps the copies left in `area` by an app that had to leave it, from the entries /// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`: -/// no app that runs keeps them. A store that can't be listed now keeps them (a copy kept loses -/// nothing). +/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is +/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be +/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> { let Ok(names) = store.list() else { return Some(Owner::Running) }; let mark = format!("/{KEEPS}{area}"); @@ -1247,14 +1248,15 @@ fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> { } /// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a -/// lock taken to tell goes at once). +/// lock taken to tell goes at once). An area whose lock fails (not a recovery area of this +/// account, a lock file that can't be opened) is nobody's. fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner { if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { return Owner::Mine; } match store.lock(area) { - Ok(Lock::Held(_)) => Owner::Gone, - Ok(Lock::Busy) | Err(_) => Owner::Running, + Ok(Lock::Held(_)) | Err(_) => Owner::Gone, + Ok(Lock::Busy) => Owner::Running, Ok(Lock::Unsupported) => { let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()); if runs { Owner::Running } else { Owner::Gone } diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 84e212c826..5a0ece5e9c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1006,6 +1006,94 @@ fn a_copy_left_behind_is_not_offered_by_another_app_while_its_app_runs() { let _ = std::fs::remove_dir_all(&dir); } +/// A store in memory whose areas named `foreign-…` can't be locked by this account (as another +/// account's folder in a shared recovery folder). +#[derive(Default)] +struct ForeignAreas(MemoryStore); + +impl RecoveryStore for ForeignAreas { + fn list(&self) -> std::result::Result<Vec<String>, String> { + self.0.list() + } + fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String> { + self.0.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.0.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.0.remove(name) + } + fn location(&self) -> String { + self.0.location() + } + fn lock(&self, area: &str) -> std::result::Result<cmd::recovery::Lock, String> { + if area.starts_with("foreign-") { + return Err(format!("{area}: belongs to another account")); + } + self.0.lock(area) + } + fn now(&self) -> Option<i64> { + self.0.now() + } +} + +/// An entry `<area>/keeps-<left area>` in an area this account can't lock keeps nothing: a crashed +/// app's copies in the left area are still offered. +#[test] +fn a_keeps_entry_in_an_area_that_cannot_be_locked_keeps_nothing() { + let store = Arc::new(ForeignAreas::default()); + let app = || { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s + }; + let mut a = app(); + a.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + drop(a); + store.write(&format!("foreign-1/keeps-{area}"), b"").unwrap(); + let mut b = app(); + assert_eq!(offered(&mut b).len(), 1); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1); +} + +/// An entry `<area>/keeps-<left area>` in a running app's area keeps a crashed app's copies in the +/// left area until that app is gone too. +#[test] +fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { + let store = Arc::new(MemoryStore::default()); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + drop(a); + let mut c = session_in(&store); + c.execute("file.recovery.save", &json!({})).unwrap(); + let keeper = c.recovery.own_area().unwrap().to_string(); + store.write(&format!("{keeper}/keeps-{area}"), b"").unwrap(); + let mut b = relaunch(&store); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + drop(c); + assert_eq!(offered(&mut b).len(), 2); +} + +/// An entry `<area>/keeps-<left area>` in a folder whose lock file can't be opened keeps nothing. +#[cfg(unix)] +#[test] +fn a_keeps_entry_in_a_folder_whose_lock_cannot_be_checked_keeps_nothing() { + let dir = std::env::temp_dir().join(format!("vc-recovery-keepsunlocked-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (a, area) = saved_in(&dir); + drop(a); + std::fs::create_dir_all(dir.join("keeper").join(".lock")).unwrap(); + std::fs::write(dir.join("keeper").join(format!("keeps-{area}")), b"").unwrap(); + assert_eq!(offered(&mut other_app(&dir)).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + /// A complex document (Data Recovery off for those) gets no new copy after the session left its /// area: it keeps the copy it left there, and the heartbeat asks for it only once. #[cfg(unix)] From 2c9f5a6a11ce89aa268bf2baba8e621109f253e4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:12:54 -0800 Subject: [PATCH 114/126] HP-GL export: an HP-GL plot past 16-bit coordinates gets a warning Coordinates outside -32,768 to 32,767 plotter units (about 82 cm from the origin) are written as they are, with a warning that older devices may not read them. HP-GL/2 plots are not checked against this range. --- crates/cad/src/hpgl/write.rs | 8 ++++++++ crates/cad/src/hpgl/write/tests.rs | 21 +++++++++++++++++++++ crates/engine/src/cmd/fileio/hpgl.rs | 2 +- docs/mcp.md | 8 +++++--- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 9e77495aa2..22773ac68b 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -15,6 +15,8 @@ //! Coordinates are plotter units (1016 to the inch, y up) from the region's bottom-left corner or //! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range //! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. +//! An HP-GL plot with a coordinate outside −32,768 to 32,767 (16-bit numbers, the range older +//! devices read) gets a warning; HP-GL/2's encoded polylines take the wider range. //! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with //! `sort`, each next path the one that starts nearest where the pen stopped. //! @@ -39,6 +41,8 @@ use clip::Region; const PLU_PER_PT: f64 = 1016.0 / 72.0; /// The largest coordinate written, plotter units. pub const MAX_COORD: i64 = (1 << 23) - 1; +/// The coordinates an HP-GL plot keeps to without a warning: 16-bit numbers, plotter units. +const CLASSIC_RANGE: std::ops::RangeInclusive<i64> = -32_768..=32_767; /// The curve tolerance a path is flattened to by default, points. pub const DEFAULT_TOLERANCE: f64 = 0.1; /// The smallest and largest curve tolerance, points. @@ -67,6 +71,7 @@ const TOO_MANY_POINTS: &str = "the export is too large: the art past its first 1 const CLIP_WORK: &str = "clipping masks are too complex to apply in full: the art past that point is drawn whole"; const MASK_POINTS: &str = "clipping masks past 10 million points together are left out: the art they clip is drawn unclipped by them"; const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; +const PAST_CLASSIC_RANGE: &str = "the plot has coordinates past 32,767 plotter units (about 82 cm) from the origin, which older HP-GL devices may not read: HP-GL/2 takes a wider range"; const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; @@ -261,6 +266,9 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R } } } + if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { + warn(&mut warnings, PAST_CLASSIC_RANGE); + } let bytes = match opts.version { Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 2093fe8b48..7ef5105340 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -454,6 +454,27 @@ fn art_past_the_plotter_range_is_cut_off() { assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;"); } +/// HP-GL past 16-bit coordinates is written as it is, with a warning; HP-GL/2 gets none. +#[test] +fn hpgl_past_16_bit_coordinates_warns() { + let region = Rect::new(-2400.0, 0.0, 2400.0, 100.0); + // Past 32,767 from the bottom-left corner, within the range from the center. + let d = doc_with(|d| vec![stroked(d, line((-2000.0, 50.0), (2000.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU5644,706;PD62089,706;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU-28222,0;PD28222,0;PU;SP0;"); + // Past −32,768 and 32,767 from the center. + let d = doc_with(|d| vec![stroked(d, line((-2400.0, 50.0), (2400.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU-33867,0;PD33867,0;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, version: Version::Hpgl2, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + #[test] fn a_plot_stops_at_its_point_budget() { let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect()); diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index 0016afa10e..a7980b5072 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -22,7 +22,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "hpglVersion", ty: "string", default: "\"hpgl\"", - description: "hpgl (IN, SP, PU and PD only) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", + description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", }, FormatOption { name: "curveTolerance", diff --git a/docs/mcp.md b/docs/mcp.md index 39ec4c777e..3494b98244 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6419,10 +6419,12 @@ are VectorCraft's own. `hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's -paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names +paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. An `"hpgl"` plot +with a coordinate outside −32,768 to 32,767 units (about 82 cm from the origin, the range of 16-bit numbers) is written +as it is, with a warning that older devices may not read it; this check is VectorCraft's own. `warnings` names type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke -looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping -work, symbols, live art, nesting depth and sorting. +looks that are not drawn, art past the plotter's range, HP-GL coordinates past 16-bit numbers, colors past 255 pens, +and the limits on points, clipping work, symbols, live art, nesting depth and sorting. ```json {"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} From a8b5a5b6c944fbe2f5d295e7f0f346540c83135a Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Thu, 12 Nov 2026 13:15:22 +0500 Subject: [PATCH 115/126] Blend: moving an end key of a closed replaced spine moves its end (#6809) On a closed spine from Replace Spine both end keys sit on its first point, so moving one moved that point onto it and then back onto the other. Once a key is moved off, the spine is now opened at its first point, with a copy of it at the end taking the closing segment and the last key. --- crates/doc/src/blend.rs | 18 +++++++++++++++++- crates/engine/src/tests/live.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 9845bab8ad..ac9f99c4f6 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1085,8 +1085,9 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) { return Some((path, None)); } - let Some(ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) }; + let Some(mut ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) }; let mut first = path.subpaths.into_iter().next()?; + open_at_start(&mut first, &mut ka); for (k, &ai) in keys.iter().zip(&ka) { if let Some(a) = first.anchors.get_mut(ai) { let d = center_of(k) - a.p; @@ -1132,6 +1133,21 @@ fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool { }) } +/// Open closed spine `sp` at its first anchor when the last key sits there with the first (as +/// [`spread_anchors`] puts them): a copy of that anchor at the end takes the closing segment and +/// the last key, so the two end keys move their ends of the spine apart. +fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) { + let Some(&start) = sp.anchors.first() else { return }; + if !sp.closed || ka.len() < 2 { + return; + } + if let Some(last) = ka.last_mut().filter(|a| **a == 0) { + *last = sp.anchors.len(); + sp.anchors.push(start); + sp.closed = false; + } +} + /// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit /// on, splitting segments where none is. fn spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> { diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 597e63c3f3..429542827e 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -455,6 +455,35 @@ fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() { assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); } +/// #6809 on a closed spine, where both end keys start on its first point: moving the first key +/// moves its end of the spine without dragging the last key's end along. +#[test] +fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + #[test] fn blend_align_to_path_rotates_steps() { let mut s = session(); From 175f51827ddd4539705145b31bca8477b388d877 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:18:05 -0800 Subject: [PATCH 116/126] Symbols: redefining a dynamic symbol drops paints its new objects do not draw When a dynamic symbol is redefined or edited, each instance kept every appearance change whose path still named an object, even when that object is now one that draws no fill or stroke of its own (an image, a blend, a mesh) or a symbol instance given a gradient or pattern fill. symbol.instance.setAppearance refuses such a paint, but the change stayed on the instance and still counted as a color the document uses. Fitting the changes to the new art now also clears a fill or stroke the object at that path does not draw, and drops a change left empty. --- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/dynamic.rs | 43 +++++++++++++++++----- crates/engine/src/cmd/symboledit.rs | 8 ++-- crates/engine/src/tests/dynamic_symbols.rs | 24 ++++++++++++ docs/mcp.md | 5 ++- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index b4b231a22b..097be73686 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,7 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -pub(crate) use dynamic::{edit_part, part_only, selected_part}; +pub(crate) use dynamic::{edit_part, fit_parts, part_only, selected_part}; use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 888e450085..f5cef8351e 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -2,7 +2,7 @@ //! appearance of objects in the symbol's art and stays linked to the symbol //! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance //! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still -//! there ([`fit_overrides`]). +//! there and draws them ([`fit_overrides`]). use std::sync::Arc; @@ -27,7 +27,7 @@ pub(super) fn type_id(dynamic: bool) -> &'static str { } /// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or -/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art. +/// missing) symbol keeps none, an instance of a dynamic one those its art still draws ([`fit_parts`]). pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { for id in ids { let art = match d.node(*id) { @@ -40,7 +40,7 @@ pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { let Some(n) = d.node_mut(*id) else { continue }; let mut e = n.extras_mut(); match art { - Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art), + Some(art) => fit_parts(&mut e.symbol_overrides, &art), None => e.symbol_overrides.clear(), } } @@ -177,17 +177,42 @@ fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<( fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { let asked = p.get("fill").is_some_and(|v| !v.is_null()); let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); - let draws_nothing = |f: &vectorcraft_color::Paint| match f { - vectorcraft_color::Paint::Gradient(_) => true, - vectorcraft_color::Paint::Pattern { .. } => true, - vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, - }; - if asked && is_instance && fill.is_some_and(draws_nothing) { + if asked && is_instance && fill.is_some_and(|f| !stains(f)) { return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); } Ok(()) } +/// Can a symbol instance's fill be `f`? An instance stains its art with a solid fill only. +fn stains(f: &vectorcraft_color::Paint) -> bool { + match f { + vectorcraft_color::Paint::Gradient(_) => false, + vectorcraft_color::Paint::Pattern { .. } => false, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => true, + } +} + +/// Keep the appearance changes `overrides` that name an object of `art` (a symbol's new art), each +/// without a fill or stroke that object does not draw ([`paint_refusal`], [`stains`]); a change +/// left with nothing in it is dropped. +pub(crate) fn fit_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) { + symbol_overrides::retain_parts(overrides, art); + for o in overrides.iter_mut() { + let Some(n) = symbol_overrides::part(art, &o.path) else { continue }; + let instance = matches!(n.kind, NodeKind::SymbolInstance { .. }); + if paint_refusal(n, "fill", FIT).is_some() || (instance && o.fill.as_ref().is_some_and(|f| !stains(f))) { + o.fill = None; + } + if paint_refusal(n, "stroke", FIT).is_some() { + o.stroke = None; + } + } + overrides.retain(|o| !o.is_empty()); +} + +/// The command name [`fit_parts`] gives [`paint_refusal`], whose refusal it does not show. +const FIT: &str = "symbol.update"; + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> { diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index a5f677f5dd..606ab40fcf 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -19,7 +19,7 @@ use vectorcraft_doc::symbol_overrides::SymbolOverride; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration}; use vectorcraft_geom::{Affine, Point}; -use super::brushsym::{natural_size, place_xf}; +use super::brushsym::{fit_parts, natural_size, place_xf}; use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param}; /// The name of the undo step that enters symbol editing mode. @@ -425,8 +425,8 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec<Sy } /// Each instance's appearance changes to objects of symbol `name`'s art follow those objects -/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes to -/// objects the art still has. That covers the instances on the layers and in the art of the +/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes the art +/// still draws ([`fit_parts`]). That covers the instances on the layers and in the art of the /// document's other symbols, graph designs, pattern tiles (the tiles pattern editing's Cancel puts /// back included) and brushes, opacity-mask art included. The editing layer holds no instance of /// the symbol, since [`sync`] refuses one. @@ -435,7 +435,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new: let mut follow = |o: &mut Vec<SymbolOverride>| { follow_paths(o, old, new); match &art { - Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art), + Some(art) => fit_parts(o, art), None => o.clear(), } }; diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 278d979ef5..c5ef8a1949 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -432,6 +432,30 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { assert_eq!(overrides(&s, inst), 0); } +#[test] +fn redefining_the_symbol_drops_paints_its_new_objects_do_not_draw() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let p = json!({"id": inst.0, "path": [1], "fill": "#ff0000", "stroke": "#ff0000", "opacity": 50}); + s.execute("symbol.instance.setAppearance", &p).unwrap(); + // The second object is now an image, which draws no fill or stroke of its own. + let a = square(&mut s, 10.0); + place_image(&mut s); + let image = top(&s); + s.execute("symbol.update", &json!({"name": "Pair", "ids": [a.0, image.0]})).unwrap(); + let o = node(&s, inst).extras().symbol_overrides.clone(); + assert_eq!(o.len(), 1, "the opacity change stays: {o:?}"); + assert_eq!((&o[0].fill, &o[0].stroke, o[0].opacity), (&None, &None, Some(0.5))); + // A change of paint alone goes with its paint: the first object is now a blend. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [0], "stroke": "#ff0000"})).unwrap(); + let (b, c) = (square(&mut s, 10.0), square(&mut s, 60.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [b.0, c.0], "steps": 1})).unwrap()); + let d = square(&mut s, 120.0); + s.execute("symbol.update", &json!({"name": "Pair", "ids": [blend.0, d.0]})).unwrap(); + let o = node(&s, inst).extras().symbol_overrides.clone(); + assert_eq!(o.iter().map(|o| o.path.clone()).collect::<Vec<_>>(), [vec![1]], "{o:?}"); +} + #[test] fn replace_symbol_drops_the_changes_made_for_the_old_symbol() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..c6a5e616d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2575,8 +2575,9 @@ already walks Image Trace. Short agent entry points: locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the - new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own - art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and + new art still has, less a fill or stroke the object now at that path does not draw (a change left empty is dropped); + `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit + there keeps the changes the same way; making the symbol static drops them, and so does `symbol.replace` with a different symbol (a replace with the instance's own symbol keeps them). ```json From a6ff24d0c40d68f78728e3fad153f77e7003a604 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:41:42 -0800 Subject: [PATCH 117/126] Tabs: the ruler and Repeat Tab stop at 256 tab stops With 256 stops, a click on an empty part of the Tabs panel's ruler adds no stop and shows "at most 256 tab stops" in the status bar; the stops and the selection stay as they were. Repeat Tab adds copies only up to 256 stops and shows the same message when it stops early. Before, both built a longer list that text.tabs.set refused, so nothing changed and no message showed. --- crates/ui-egui/src/panels/tabs.rs | 32 +++++++++++++++++-------- crates/ui-egui/src/panels/tabs/tests.rs | 29 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index fb12e4fdc0..6a984a1ced 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -686,15 +686,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // A click that lands on a stop's whole point or tick selects that stop and records no // undo step. let added = !stops.iter().any(|s| s.position == position); - if added { - stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); - stops.sort_by(|a, b| a.position.total_cmp(&b.position)); - } - let i = stops.iter().position(|s| s.position == position).unwrap_or(0); - set_pstate::<Option<IndentMarker>>(ui.ctx(), "tabs-marker-sel", None); - set_pstate(ui.ctx(), "tabs-sel", i); - if added { - apply(app, &stops); + let max = TabStop::MAX_STOPS; + if added && stops.len() >= max { + // A full list takes no more stops: the click leaves the stops and the + // selection as they are and shows the limit. + app.status(format!("at most {max} tab stops")); + } else { + if added { + stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + let i = stops.iter().position(|s| s.position == position).unwrap_or(0); + set_pstate::<Option<IndentMarker>>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); + if added { + apply(app, &stops); + } } } } @@ -729,7 +736,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the // previous stop, or from the paragraph's left indent plus its list level's indent when it is - // the first stop. + // the first stop. The copies stop at the 256-stop limit, which the status bar then shows. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { 0 => (stops[0].position - left_indent).abs(), @@ -738,8 +745,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { .max(1.0); let base = stops[sel].clone(); stops.truncate(sel + 1); + let max = TabStop::MAX_STOPS; let mut pos = base.position + step; while pos <= base.position + step * 20.0 && pos < 2000.0 { + if stops.len() >= max { + app.status(format!("at most {max} tab stops")); + break; + } stops.push(TabStop { position: pos, ..base.clone() }); pos += step; } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 9e96866ab2..97e8c8a00a 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1255,6 +1255,35 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { assert_eq!(current(&app).unwrap().1, before); } +/// With 256 stops, a click on an empty part of the ruler adds none and shows the limit in the +/// status bar. Repeat Tab near the end of the list adds copies only up to 256 stops and shows the +/// limit too. +#[test] +fn the_ruler_and_repeat_tab_stop_at_256_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + // 250 stops 1 pt apart from 0 pt, then six 2 pt apart from 260 pt. + let start: Vec<f64> = (0..250).map(f64::from).chain((0..6).map(|i| 260.0 + 2.0 * f64::from(i))).collect(); + let stops: Vec<Value> = start.iter().map(|p| json!({"position": p})).collect(); + app.session.execute("text.tabs.set", &json!({ "stops": stops })).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::<Vec<_>>(); + set_pstate(&ctx, "tabs-sel", 3usize); + drag_on_ruler(&ctx, &mut app, 290.0, 290.0, false); + assert_eq!(positions(&app), start); + assert_eq!(pstate::<usize>(&ctx, "tabs-sel"), 3); + assert_eq!(app.ui.status, "at most 256 tab stops"); + // Repeat Tab on the stop at 260 pt (11 pt past the one before it): five copies, not 20. + app.status(""); + set_pstate(&ctx, "tabs-sel", 250usize); + choose(&ctx, &mut app, "Repeat Tab"); + let repeated = positions(&app); + assert_eq!(repeated.len(), 256); + assert_eq!(repeated[249..], [249.0, 260.0, 271.0, 282.0, 293.0, 304.0, 315.0]); + assert_eq!(app.ui.status, "at most 256 tab stops"); +} + /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's /// left indent (18 pt further in for each list level past the first), from the paragraph the /// caret is in while the Type tool edits text. From ee524f06c9aa4be8c1c4473e5a617cad70cc9a08 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:15:05 -0800 Subject: [PATCH 118/126] Tabs: text.tabs.set can shrink a paragraph that holds more than 256 stops text.tabs.set takes a list of more than 256 stops when every paragraph it targets already holds at least that many, so a paragraph loaded with more stops can lose them one at a time (the Tabs panel's Delete Tab). Any other list of more than 256 stops is still refused, counted as supplied before stops at one position merge. Docs: docs/mcp.md. --- crates/engine/src/cmd/tabs.rs | 79 +++++++++++++++++++++++++++++++---- docs/mcp.md | 5 ++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 04a2c5504a..c047b94900 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -38,10 +38,6 @@ pub fn specs() -> Vec<CommandSpec> { fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { const C: &str = "text.tabs.set"; let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; - let max = TabStop::MAX_STOPS; - if arr.len() > max { - return Err(bad(C, format!("at most {max} tab stops"))); - } let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; @@ -90,10 +86,39 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { } fn set(s: &mut Session, p: &Value) -> Result<Value> { + // More than the limit is taken only when every targeted paragraph already holds at least as + // many stops, so a paragraph that holds more can still lose them one at a time. The supplied + // list is counted before it is read. + let max = TabStop::MAX_STOPS; + let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len); + if supplied > max && supplied > fewest_held(s, p)? { + return Err(bad("text.tabs.set", format!("at most {max} tab stops"))); + } let stops = parse_stops(p)?; apply(s, p, stops, "Tab Stops") } +/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none). +fn fewest_held(s: &Session, p: &Value) -> Result<usize> { + let ids = text_targets(s, p, "text.tabs.set")?; + let range = TextRange::parse(p, "text.tabs.set")?; + let doc = &s.doc()?.doc; + let mut fewest: Option<usize> = None; + for id in &ids { + if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) { + let held = match para_span(range, t) { + None => t.para_styles().map(|pa| pa.tabs.len()).min(), + Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(), + }; + fewest = match (fewest, held) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + } + } + Ok(fewest.unwrap_or(0)) +} + fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> { let ids = text_targets(s, p, "text.tabs.set")?; let range = TextRange::parse(p, "text.tabs.set")?; @@ -159,14 +184,52 @@ mod tests { let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap(); let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect(); - assert!(err(Value::from(many.clone())).contains("at most 256 tab stops")); - let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap(); - assert_eq!(ok.len(), 256); + let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string(); + assert!(e.contains("at most 256 tab stops"), "{e}"); + // The supplied list is counted, not the stops left once those at one position merge. + let same: Vec<Value> = (0..257).map(|_| json!({"position": 5})).collect(); + assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256)); let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); } + /// A paragraph that already holds more than 256 stops (from a file) can lose them one at a + /// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and + /// a paragraph with fewer stops targeted beside it keeps the limit. + #[test] + fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() { + use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap()); + let held: Vec<TabStop> = + (0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect(); + s.edit("Load", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.edit_paras(None, |pa| pa.tabs = held.clone()); + } + Ok(()) + }) + .unwrap(); + let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::<Vec<_>>() }); + assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299)); + assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299); + // The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused, + // and 298 for the first alone is taken. + s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap(); + let mut both = list(298); + assert!(s.execute("text.tabs.set", &both).is_err()); + both["start"] = json!(0); + both["end"] = json!(0); + assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298)); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..50a8a62e0c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7841,8 +7841,9 @@ targeted objects, as before. With a range, the character attributes of `text.set that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or `alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than -8 characters, a position outside 0 to 100000 pt, and more than 256 stops. It sorts the stops by position and keeps the -first of stops at one position. +8 characters, a position outside 0 to 100000 pt, and more than 256 stops unless every paragraph it targets already +holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and +keeps the first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in From 87ab5b445f27934cd68741e974a3c580a33d2628 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:21:44 -0800 Subject: [PATCH 119/126] HP-GL export: the writer refuses a bad curve tolerance or more than 255 pens A tolerance outside 0.01 to 10 points, or one that is not finite, and a pen table of more than 255 colors are errors instead of being clamped or cut short. document.export already refuses both. --- crates/cad/src/hpgl/write.rs | 15 +++++++++++---- crates/cad/src/hpgl/write/tests.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 22773ac68b..80ac40f866 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -142,9 +142,11 @@ impl Origin { #[derive(Clone, Debug)] pub struct HpglOptions { pub version: Version, - /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]). + /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]; a value + /// outside it is an error). pub tolerance: f64, - /// The pen table: pen 1's color first. Empty: a pen for each color the art draws in. + /// The pen table: pen 1's color first, up to [`MAX_PENS`] (more is an error). Empty: a pen for + /// each color the art draws in. pub pens: Vec<[u8; 3]>, pub origin: Origin, /// Draw each pen's paths nearest first, instead of in paint order. @@ -184,8 +186,13 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if regions.iter().any(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { return Err("the export region is empty".into()); } - let tolerance = - if opts.tolerance.is_finite() { opts.tolerance.clamp(*TOLERANCE_RANGE.start(), *TOLERANCE_RANGE.end()) } else { DEFAULT_TOLERANCE }; + if !TOLERANCE_RANGE.contains(&opts.tolerance) { + return Err("the curve tolerance must be from 0.01 to 10 points".into()); + } + if opts.pens.len() > MAX_PENS { + return Err("the pen table holds more than 255 pens".into()); + } + let tolerance = opts.tolerance; // Layers the plot leaves out go first: baking can wrap one in art that would be drawn (its // crop marks). let shown = plotted(&doc.layers, false).map(|layers| { diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 7ef5105340..0917cf02c9 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -441,6 +441,22 @@ fn an_empty_or_zero_length_plot_has_no_moves() { assert!(export(&d, &nan).is_err()); } +/// A tolerance outside its range and a pen table of more than 255 pens are errors, not changed. +#[test] +fn bad_options_are_errors() { + let d = three(); + for tolerance in [0.0, 0.009, 10.01, f64::NAN, f64::INFINITY] { + let o = HpglOptions { tolerance, ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the curve tolerance must be from 0.01 to 10 points"), "{tolerance}"); + } + for tolerance in [0.01, 10.0] { + assert!(export(&d, &HpglOptions { tolerance, ..opts(&d) }).is_ok(), "{tolerance}"); + } + let o = HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS + 1], ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the pen table holds more than 255 pens")); + assert!(export(&d, &HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS], ..opts(&d) }).is_ok()); +} + #[test] fn art_past_the_plotter_range_is_cut_off() { let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]); From 1c3c3e81ddf15bbb5936d94e2e6a48ba6f29f05f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:21:48 -0800 Subject: [PATCH 120/126] Type: Convert To Area Type and Convert To Point Type keep vertical type in place Vertical type lays its lines out in line space and turns them a quarter turn into text space. Convert To Area Type built the frame from line-space line edges mixed with text-space bounds, so vertical point type moved (a 12 pt line at (295.44, 40) went to (320.8, 28)). Convert To Point Type placed the new origin in line space, so vertical area type moved down its column. Both conversions now work out the frame or origin in line space and map it through the layout's line transform. Horizontal type is unchanged. --- crates/engine/src/cmd/typemenu.rs | 14 +++++-- crates/engine/src/tests/textedit.rs | 62 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index bb0e426c9b..239ee748bf 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -311,7 +311,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // The frame is worked out in line space, where the lines are; vertical type turns it + // into text space with the lines. + let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds); // Across, the frame spans the lines as they are aligned: from their starts to their ends // without the tracking after their last characters (`LineInfo::trailing`), not to the // ink, which tracking can pull past an advance. Area type aligns them alike. @@ -332,7 +335,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), _ => (0.0, slack), }; - let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); + let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0))); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); } @@ -368,6 +371,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { // Point type is in no thread: its first paragraph starts in it. t.first_para_continues = false; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + // The lines are in line space; the frame's bounds are taken there too. + let fb = lay.line_xf.inverse().transform_rect_bbox(fb); let plain = t.plain_text(); let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); // Soft wraps → explicit breaks (replace the space before the wrap when there is one). @@ -414,7 +419,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { // converting back to area type does not turn it on again. t.dynamic_text = false; t.kind = TextKind::Point; - t.xf *= Affine::translate((ox, baseline)); + // The origin is in the area type's line space; point type's line space puts its own + // origin there (vertical point type turns its lines about a different point). + let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY }; + t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse(); refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9eea9cf456..243d6d0081 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2523,6 +2523,68 @@ fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { } } +/// Horizontal and vertical type keep every line where it is when converted from point to area +/// type and back, and from area to point type and back, however the lines are aligned. Vertical +/// lines run down their columns, and each next column sits to the left of the one before it. +#[test] +fn converting_vertical_type_between_point_and_area_keeps_it_in_place() { + for vertical in [false, true] { + for j in ["left", "center", "right"] { + for text in ["Wave", "Wave\nWaves"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 300, "y": 40, "text": text, "size": 12, "vertical": vertical})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), text.lines().count()); + if vertical && before.len() > 1 { + assert!(before[1].x < before[0].x, "{j}: the second column is to the left: {before:?}"); + } + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + let area = line_starts_doc(&t); + assert_eq!(area.len(), before.len(), "vertical {vertical}, {j}, {text:?}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + let point = line_starts_doc(&t); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type to point type and back. + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 300, "y": 40, "text": "Wave\nWaves", "size": 12, "vertical": vertical, "area": {"width": 120, "height": 120}}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, point: {before:?} → {point:?}"); + } + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, area again: {before:?} → {area:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session(); From e68cbee1624c5a5c2aff528194b06c0b464ca9df Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:39:05 -0800 Subject: [PATCH 121/126] 3D (Classic): format the transparent-gradient test; list Revolve (Classic) as shipped The 3D and Materials row of docs/target-app-parity.md names Revolve (Classic) with its options and surfaces among what ships, and its evidence names the tests that cover it. --- crates/effects/src/revolve/classic.rs | 3 ++- docs/target-app-parity.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index 9a3d98a380..1881381567 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -161,7 +161,8 @@ mod tests { let art = |end_opacity: f32| { let mut end = GradientStop::new(1.0, Color::BLACK); end.opacity = end_opacity; - let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))); + let gradient = + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))); let mut b = BezPath::new(); b.move_to((150.0, 60.0)); b.line_to((170.0, 60.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c1fb53790..b5082230b6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -228,7 +228,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | From 044c8047f65d77b684328585960d5f3255aa7cd3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:39:41 -0800 Subject: [PATCH 122/126] Scripts: an error in code an earlier #targetengine run compiled names that run's file and line A function an earlier run of a named engine defined was reported without a line, column or file when it threw in a later run. The realm now gives each source a tag that stays the same from run to run (Realm::error_tag, Realm::script_tag), and no two sources share one once the tags run out. Each named engine keeps the source maps of its runs' scripts, included files and $.evalFile files, within 8 MiB, dropping the oldest first, and an error in kept code is reported at that file, line and column. The report's name and the console line keep the calling run's name. For an earlier script given as source, file is that run's name. Docs: docs/mcp.md (Script engines), script.run params text, docs/target-app-parity.md. --- crates/engine/src/cmd/scripts.rs | 2 +- crates/engine/src/script/include.rs | 7 + crates/engine/src/script/mod.rs | 173 ++++++++++++++++++---- crates/engine/src/tests/script_engines.rs | 112 +++++++++++++- crates/js/src/host.rs | 2 +- crates/js/src/host_tests.rs | 31 ++++ crates/js/src/realm.rs | 37 ++++- docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 9 files changed, 335 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8567a7ec21..8c86f0dfe3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index ce2e1d6783..4ec68dbbde 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -88,6 +88,13 @@ impl SourceMap { let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from("")); (seg.file, name, seg.line.saturating_add(line - seg.out_line), column) } + + /// The memory the map holds, in bytes: its lists and its file names, each name counted in full + /// even when another map shares it. + pub(crate) fn bytes(&self) -> usize { + let names: usize = self.files.iter().map(|f| f.len() + 2 * size_of::<usize>()).sum(); + self.files.capacity() * size_of::<Arc<str>>() + self.segments.capacity() * size_of::<Segment>() + names + } } /// How a preprocessor failure ends the run. diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index c3b3084e21..0821a11e71 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -221,12 +221,69 @@ fn current(link: &Link) -> std::result::Result<Rc<Host>, HostError> { } /// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), -/// the link its host functions use, and its heap after its last run. +/// the link its host functions use, its heap after its last run, and the source maps of the code +/// its earlier runs compiled. struct Engine { name: String, realm: Option<Realm>, link: Link, heap: usize, + maps: Rc<RefCell<KeptMaps>>, +} + +/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its +/// kept source maps. +type TakenEngine = (Option<Realm>, Link, Rc<RefCell<KeptMaps>>); + +/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its +/// earlier runs. +pub const KEPT_MAP_BYTES: usize = 8 << 20; + +/// A source map an engine kept from an earlier run: the realm's tag for the source +/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for +/// a script given as text. +struct KeptMap { + tag: u32, + map: include::SourceMap, + script: Option<Arc<str>>, + bytes: usize, +} + +/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most +/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept. +#[derive(Default)] +struct KeptMaps { + maps: std::collections::VecDeque<KeptMap>, + bytes: usize, +} + +impl KeptMaps { + fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option<Arc<str>>) { + let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len()); + let limit = kept_map_bytes(); + if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) { + return; + } + while self.bytes + bytes > limit { + let Some(old) = self.maps.pop_front() else { break }; + self.bytes -= old.bytes; + } + self.bytes += bytes; + self.maps.push_back(KeptMap { tag, map, script, bytes }); + } + + fn get(&self, tag: u32) -> Option<&KeptMap> { + self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i)) + } +} + +/// The bytes of source maps a named engine keeps. +fn kept_map_bytes() -> usize { + #[cfg(test)] + if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) { + return n; + } + KEPT_MAP_BYTES } /// The named engines of one thread's sessions, by their key, each in the order they were made. @@ -665,6 +722,14 @@ struct Host { /// The file, line and column of each error `$.evalFile` raised for a file it could not run, in /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>, + /// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and + /// [`vectorcraft_js::UNKNOWN_SOURCE`] before. + script_tag: Cell<u32>, + /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. + report_name: Arc<str>, + /// The source maps the named engine of the run kept from its earlier runs; `None` for a run + /// without one. + kept_maps: Option<Rc<RefCell<KeptMaps>>>, /// When the run started, where the platform has a clock. started: Option<std::time::Instant>, /// The time since [`Host::started`] at which a `$.sleep` ends. @@ -740,6 +805,23 @@ pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec<c } impl Host { + /// Gives the named engine of the run the source maps of the code the run compiled: its script + /// and each file `$.evalFile` gave. A run that never started its script compiled nothing. + fn keep_maps(&self) { + let (Some(kept), tag) = (&self.kept_maps, self.script_tag.get()) else { return }; + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return; + } + let mut kept = kept.borrow_mut(); + kept.keep(tag, std::mem::take(&mut *self.script_map.borrow_mut()), Some(self.report_name.clone())); + for (n, map) in std::mem::take(&mut *self.eval_maps.borrow_mut()).into_iter().enumerate() { + match u32::try_from(n + 1).ok().and_then(|n| tag.checked_add(n)).filter(|t| *t != vectorcraft_js::UNKNOWN_SOURCE) { + Some(t) => kept.keep(t, map, None), + None => break, + } + } + } + /// Adds a line to the run's console and, during a slice, to the session's. fn line(&self, entry: console::Entry, cut: bool) { let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; @@ -1358,34 +1440,55 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) { (RunError { kind, message, line: f.line, column: f.column }, file) } -/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark` -/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name -/// unless it is the script's own. -fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) { - let map = |m: &include::SourceMap, own: bool| { +/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in, +/// with that file's name unless it is the run's own script. +fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option<Arc<str>>) { + let map = |m: &include::SourceMap| { let (index, file, line, column) = m.locate(e.line, e.column); let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; - (RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file)) + (RunError { kind: e.kind.clone(), message, line, column }, index, file) }; // An error `$.evalFile` raised for a file it could not run, and no script caught, is at that // file's place. - if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { + if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; return (RunError { kind: e.kind.clone(), message, line, column }, Some(file)); } if e.line == 0 { return (e.clone(), None); } - // An error in code from another source, such as a function an earlier run of a named engine - // defined, has no place in this run's files, so it is given without one. + // An error in code from a source whose map is not at hand, such as code that `eval` compiled, + // is given without a place. let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None); - match tag { - 0 => map(&host.script_map.borrow(), true), + match realm.error_source() { + 0 => { + let (e, index, file) = map(&host.script_map.borrow()); + (e, (index > 0).then_some(file)) + } n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { - Some(m) => map(m, false), + Some(m) => { + let (e, _, file) = map(m); + (e, Some(file)) + } None => unplaced(), }, - _ => unplaced(), + // Code an earlier run of the named engine compiled, such as a function it defined, is at + // its place in that run's files: the run's script, a file it included, or a file + // `$.evalFile` gave. A script given as text is named by that run's name. + _ => { + let kept = host.kept_maps.as_ref().map(|k| k.borrow()); + match kept.as_ref().and_then(|k| k.get(realm.error_tag())) { + Some(kept) => { + let (e, index, file) = map(&kept.map); + let file = match &kept.script { + Some(name) if index == 0 && file.is_empty() => name.clone(), + _ => file, + }; + (e, Some(file)) + } + None => unplaced(), + } + } } } @@ -1438,17 +1541,17 @@ impl Session { } } - /// The realm of the named engine `name` for a run, with the link its host functions use: the - /// realm its last run left, or `None` for an engine made now. → the kind and text of the - /// failure when the engine cannot be had. - fn take_engine(&mut self, name: &str) -> std::result::Result<(Option<Realm>, Link), (include::FailKind, String)> { + /// The realm of the named engine `name` for a run, with the link its host functions use and the + /// source maps it kept: the realm its last run left, or `None` for an engine made now. → the + /// kind and text of the failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result<TakenEngine, (include::FailKind, String)> { if cfg!(target_arch = "wasm32") { return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); } with_engines(&self.script_engines, |engines| { if let Some(e) = engines.iter_mut().find(|e| e.name == name) { let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; - return Ok((Some(realm), e.link.clone())); + return Ok((Some(realm), e.link.clone(), e.maps.clone())); } if engines.len() >= MAX_ENGINES { return Err(( @@ -1457,15 +1560,20 @@ impl Session { )); } let link: Link = Rc::new(RefCell::new(None)); - engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); - Ok((None, link)) + let maps = Rc::new(RefCell::new(KeptMaps::default())); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() }); + Ok((None, link, maps)) }) } /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer - /// holds the run, and the engine keeps `realm`, or is dropped without one. + /// holds the run, and the engine keeps `realm`, with the source maps of the code the run + /// compiled, or is dropped without one. fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) { let host = link.borrow_mut().take(); + if let (Some(h), Some(_)) = (&host, &realm) { + h.keep_maps(); + } drop(host); let Some(name) = engine else { return }; let mut realm = realm; @@ -1668,9 +1776,9 @@ impl Session { }; let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; - let (engine, realm, link) = match kept { - None => (None, None, Rc::new(RefCell::new(None))), - Some((engine, (realm, link))) => (Some(engine), realm, link), + let (engine, realm, link, kept_maps) = match kept { + None => (None, None, Rc::new(RefCell::new(None)), None), + Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)), }; // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them // from its first run, and they act for the run that calls them. @@ -1716,6 +1824,9 @@ impl Session { script_map: RefCell::default(), eval_maps: RefCell::default(), eval_failures: RefCell::default(), + script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE), + report_name: Arc::from(name.as_str()), + kept_maps, started: clock(), wake_at: Cell::new(None), can_wait: realm.wait_flag(), @@ -1752,8 +1863,12 @@ impl Session { .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) - .and_then(|()| realm.start(&expanded.source)) - .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) + .and_then(|()| { + let r = realm.start(&expanded.source); + host.script_tag.set(realm.script_tag()); + r + }) + .map_err(|e| located(&host, &realm, &e)) } }; if let Err((e, file)) = started { @@ -1832,7 +1947,7 @@ impl Session { fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> { job.host.dialogs.stopped(); let fuel_left = job.realm.fuel_left(); - let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e); + let (e, file) = located(&job.host, &job.realm, e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); self.end_job(job, e.kind == RunErrorKind::Internal); @@ -2127,6 +2242,8 @@ thread_local! { pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) }; /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. pub(crate) static TEST_ENGINE_HEAP: Cell<Option<usize>> = const { Cell::new(None) }; + /// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`]. + pub(crate) static TEST_KEPT_MAP_BYTES: Cell<Option<usize>> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 5506cb98d1..3171657b32 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for}; +use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, TEST_KEPT_MAP_BYTES, engines_on_this_thread, releases_kept_for}; use crate::*; fn run(s: &mut Session, source: &str) -> Result<Value> { @@ -119,11 +119,115 @@ fn only_the_script_itself_names_its_engine() { assert_eq!(report["line"], 2, "{report}"); assert_eq!(names(&mut s), ["e"]); let _ = std::fs::remove_dir_all(base); - // An error in a function an earlier run defined has no place in the run that calls it. + // An error in a function an earlier run defined is at that run's line; a script given as text + // is named by its run's name. run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap(); - assert!(run(&mut s, "#targetengine e\nbad();").is_err()); + let e = run(&mut s, "#targetengine e\nbad();").unwrap_err().to_string(); let report = s.script_report().unwrap().clone(); - assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}"); + assert_eq!((report["name"].as_str(), report["file"].as_str(), report["line"].as_u64()), (Some("t.js"), Some("t.js"), Some(5)), "{report}"); + let column = report["column"].as_u64().unwrap(); + assert_eq!(error_lines(&report), [format!("t.js: t.js:5:{column}: Error: kept")]); + assert!(e.contains(&format!("line 5, column {column} of t.js")), "{e}"); +} + +/// The console error lines of a run's report. +fn error_lines(report: &Value) -> Vec<String> { + let lines = report["console"]["lines"].as_array().unwrap(); + lines.iter().filter_map(Value::as_str).filter(|l| l.contains("Error: ")).map(str::to_string).collect() +} + +/// The file, line and column of the last run's error. +fn place(s: &Session) -> (Option<String>, u64, u64) { + let r = s.script_report().unwrap(); + (r["file"].as_str().map(str::to_string), r["line"].as_u64().unwrap(), r["column"].as_u64().unwrap()) +} + +/// An error in code an earlier run of a named engine compiled is at the place of that code: in that +/// run's script, a file it included or a file `$.evalFile` gave, while the report and the console +/// line keep the calling run's name. +#[test] +fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-places-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("inc.jsxinc"), "\nfunction fromInclude() { throw new Error('inc'); }").unwrap(); + std::fs::write(base.join("ev.jsxinc"), "\n\n\nfunction fromEval() { throw new Error('ev'); }").unwrap(); + let def = base.join("def.jsx"); + let text = "#targetengine e\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');\nfunction fromMain() {\n throw new Error('main');\n}\n\ + function spin() {\n while (true) {}\n}\nfunction badEval() {\n eval('var = 1');\n}"; + std::fs::write(&def, text).unwrap(); + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + let call = |s: &mut Session, code: &str| { + let e = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"})); + assert!(e.is_err(), "{code}"); + }; + let shown = def.to_string_lossy().to_string(); + let ends = |p: &(Option<String>, u64, u64), file: &str, line: u64| p.0.as_deref().is_some_and(|f| f.ends_with(file)) && p.1 == line && p.2 > 0; + call(&mut s, "fromMain();"); + let p = place(&s); + assert!(ends(&p, "def.jsx", 5), "{p:?}"); + let report = s.script_report().unwrap().clone(); + assert_eq!(report["name"], "caller.js"); + assert_eq!(error_lines(&report), [format!("caller.js: {}:5:{}: Error: main", p.0.as_deref().unwrap(), p.2)]); + call(&mut s, "fromInclude();"); + assert!(ends(&place(&s), "inc.jsxinc", 2), "{:?}", place(&s)); + call(&mut s, "fromEval();"); + assert!(ends(&place(&s), "ev.jsxinc", 4), "{:?}", place(&s)); + call(&mut s, "badEval();"); + assert!(ends(&place(&s), "def.jsx", 11), "a syntax error eval raises is at its call: {:?}", place(&s)); + let e = s.execute("script.run", &json!({"source": "#targetengine e\nspin();", "name": "caller.js", "maxFuel": 100_000})); + assert!(e.is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "limit"); + assert!(ends(&place(&s), "def.jsx", 8), "the fuel ran out in spin: {:?}", place(&s)); + // A caught error thrown again is where that throw is, in the calling run. + call(&mut s, "try { fromMain(); } catch (x) {\n throw x;\n}"); + assert_eq!(place(&s), (None, 4, 3)); + // A run that fails before it starts compiles nothing, and the kept places stay. + assert!(run(&mut s, "#targetengine e\n#include \"missing.jsxinc\"").is_err()); + call(&mut s, "fromMain();"); + assert!(ends(&place(&s), "def.jsx", 5), "{:?}", place(&s)); + assert!(place(&s).0.is_some_and(|f| f == shown)); + // A dropped engine takes its places with it: the new one has its own. + s.execute("script.engine.drop", &json!({"name": "e"})).unwrap(); + run(&mut s, "#targetengine e\nfunction fromMain() { throw new Error('new'); }").unwrap(); + call(&mut s, "fromMain();"); + assert_eq!(place(&s).0.as_deref(), Some("t.js")); + assert_eq!(place(&s).1, 2); + // The name stands for the earlier script as that run's report gave it, not as console lines + // show it. + s.execute("script.run", &json!({"source": "#targetengine e\nfunction named() { throw 1; }", "name": "a\u{1}b.js"})).unwrap(); + call(&mut s, "named();"); + assert_eq!(place(&s).0.as_deref(), Some("a\u{1}b.js")); + let _ = std::fs::remove_dir_all(base); +} + +/// A named engine keeps the places of its earlier runs' code within its budget, the oldest going +/// first, and the code still runs once its place is gone. +#[test] +fn an_engine_keeps_the_newest_places_within_its_budget() { + let mut s = Session::new(); + run(&mut s, "#targetengine e\nfunction a() { throw new Error('a'); }").unwrap(); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300))); + run(&mut s, "#targetengine e\n\nfunction b() { throw new Error('b'); }").unwrap(); + for _ in 0..20 { + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + } + run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap(); + // Each run's own map takes the room of the one before, so c is called first. + assert!(run(&mut s, "#targetengine e\nc();").is_err()); + let c_place = place(&s); + let a = run(&mut s, "#targetengine e\na();").map(drop); + let a_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1))); + run(&mut s, "#targetengine e\n\n\n\nfunction d() { throw new Error('d'); }").unwrap(); + assert!(run(&mut s, "#targetengine e\nd();").is_err()); + let d_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + assert!(a.is_err()); + assert_eq!((a_place.0, a_place.1, a_place.2), (None, 0, 0), "a's place went to make room"); + assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4)); + assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } /// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 9c14bedca1..fbb5e4fc90 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -395,7 +395,7 @@ impl Interp { Err(e) => return Err(self.host_error(e)?), }; let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); - self.evaluated = self.evaluated.saturating_add(1); + self.evaluated = crate::realm::next_tag(self.evaluated); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); // UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the // bytes is taken before the source is encoded, and the rest after. A source within the diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 124fe395f3..de252c6c64 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -638,6 +638,37 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn error_tags_stay_the_same_from_run_to_run_and_are_never_shared() { + let mut r = realm(10_000_000); + let source: HostFunction = Rc::new(|_| Ok(Data::String("function inEval() { throw new Error('in eval'); }".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("load".into(), load)]).unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "no script yet"); + r.run("function late() { throw new Error('late'); }\nhost.load(); host.load();").unwrap(); + let first = r.script_tag(); + assert_ne!(first, UNKNOWN_SOURCE); + assert!(r.run("\nlate()").is_err()); + let second = r.script_tag(); + assert!(second > first + 2, "the evaluator's two sources took the tags after the first script"); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, first), "late keeps its script's tag"); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), first + 2, "the second source the evaluator gave in the first run"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (0, r.script_tag())); + // Once the tags are used up, a source gets no tag rather than one an earlier source has. + r.set_last_tag(UNKNOWN_SOURCE - 3); + r.run("function nearEnd() { throw 1; }\nhost.load(); host.load(); host.load();").unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("nearEnd()").is_err()); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "a script after the last tag"); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE, "the third source came after the last tag"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, UNKNOWN_SOURCE)); +} + #[test] fn an_evaluators_source_takes_fuel_before_it_is_parsed() { // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index b1651d1291..077f3b19b6 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -63,6 +63,12 @@ pub enum LimitKind { /// evaluator's: code that `eval` or `Function` compiled, or an error with no place. pub const UNKNOWN_SOURCE: u32 = u32::MAX; +/// The source tag after `tag`. Once the tags are used up, every later source gets +/// [`UNKNOWN_SOURCE`], so no two sources share a tag. +pub(crate) fn next_tag(tag: u32) -> u32 { + if tag >= UNKNOWN_SOURCE - 1 { UNKNOWN_SOURCE } else { tag + 1 } +} + /// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run /// it cannot pause. pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; @@ -151,7 +157,9 @@ pub struct Realm { unbroken_fuel: u64, /// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each /// script gets a new one, and the sources an evaluator gives during its run get the ones after - /// it, so code that an earlier script defined keeps a tag of its own. + /// it, so code that an earlier script defined keeps a tag of its own. [`UNKNOWN_SOURCE`] + /// before the first script, for a script the realm did not compile, and once the tags are used + /// up. script_tag: u32, } @@ -170,7 +178,7 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: UNKNOWN_SOURCE } } /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default @@ -226,6 +234,7 @@ impl Realm { /// Compiles `source` and readies the realm for a run. fn compile(&mut self, source: &str) -> Result<std::rc::Rc<crate::bytecode::Code>, RunError> { + self.script_tag = UNKNOWN_SOURCE; let interp = &mut self.interp; if let Some(message) = &interp.install_error { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); @@ -244,7 +253,7 @@ impl Realm { interp.finally_sites.clear(); interp.error_mark = None; let strict = interp.options.strict; - interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1); + interp.evaluated = next_tag(interp.evaluated); self.script_tag = interp.evaluated; interp.source_tag = self.script_tag; let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)); @@ -397,6 +406,28 @@ impl Realm { } } + /// The source tag of the code the last error's `line` and `column` are in, which stays the same + /// from run to run: each script given to [`Realm::run`] or [`Realm::start`] and each source a + /// [`crate::HostItem::Evaluator`] gives gets the next tag, so a function an earlier script + /// defined has its script's tag. [`UNKNOWN_SOURCE`] for code that `eval` or `Function` + /// compiled, an error with no place, and sources given once the tags are used up. + pub fn error_tag(&self) -> u32 { + self.interp.error_tag + } + + /// The source tag ([`Realm::error_tag`]) of the script the last [`Realm::run`] or + /// [`Realm::start`] compiled; the `n`th source an evaluator gave during that run has this tag + /// plus `n`. [`UNKNOWN_SOURCE`] when that call compiled nothing. + pub fn script_tag(&self) -> u32 { + self.script_tag + } + + /// Tests: makes `tag` the last source tag given out. + #[cfg(test)] + pub(crate) fn set_last_tag(&mut self, tag: u32) { + self.interp.evaluated = tag; + } + /// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no /// `catch` clause took that error's exception on its way out; `None` otherwise. pub fn error_mark(&self) -> Option<u32> { diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..1a50f9c26f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1873,8 +1873,13 @@ no engines, and the engines it left are dropped when their own thread next uses and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run that goes past it ends with the heap limit, and the engine stays usable. - The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps - the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run - defined is reported without a line and column. + the globals the run set before it ended. An internal error drops the engine. +- An error in code an earlier run compiled, such as a function it defined, is reported at that code's line and column + in the earlier run's script, a file it included or a file its `$.evalFile` ran, and the report's `file` names that + file. For an earlier script given as `source`, `file` is that run's name. The report's `name` and the console line's + name stay the calling run's, as in `caller.js: /path/lib.jsx:12:5: Error: kept`. Each engine keeps these places + for 8 MiB of source maps, the oldest going first; an error in code whose place is gone, or in code that `eval` or + `Function` compiled, is reported without a line and column, and the code still runs. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..c51d398bee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -756,6 +756,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. + - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. `crates/engine/src/tests/script_engines.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. From fb9a3a813be450f79d6424cc32b2b1e411800ab8 Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Thu, 12 Nov 2026 13:57:30 +0500 Subject: [PATCH 123/126] Blend: pinning a closed replaced spine gives its end keys points of their own (#6809) Pinning a closed spine from Replace Spine (a spine edit, Reverse Spine) stored it closed with both end keys on its first point, so moving the first key afterwards landed it on the last. Pinning now opens the spine there as the layout of a moved key does. --- crates/doc/src/blend.rs | 8 ++++++-- crates/engine/src/tests/live.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ac9f99c4f6..4e51777060 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1116,8 +1116,12 @@ pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec< && pinned(spec, keys.len(), first.anchors.len()).is_none() { let mut path = PathData::single(first.clone()); - let out = spread_anchors(&mut path, keys.len())?; - return Some((path, out)); + let mut out = spread_anchors(&mut path, keys.len())?; + // A closed spine's end keys get points of their own, so that once pinned each still + // moves only its own end. + let mut sp = path.subpaths.into_iter().next()?; + open_at_start(&mut sp, &mut out); + return Some((PathData::single(sp), out)); } let (path, anchors) = blend_spine(keys, spec)?; Some((path, anchors?)) diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 429542827e..0cc01bdae5 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -484,6 +484,33 @@ fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() { assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); } +/// #6809 on a closed spine pinned by a spine edit: the end keys get points of their own, so +/// moving the first key afterwards still moves only its end. +#[test] +fn moving_the_first_key_of_a_pinned_closed_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + // Dragging a corner away from the keys pins the spine to them. + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 2, "x": 320, "y": 320})).unwrap(); + let NodeKind::Blend { spec, .. } = node(&s, g).kind else { panic!() }; + assert_eq!(spec.key_anchors.len(), 2, "{:?}", spec.key_anchors); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); +} + #[test] fn blend_align_to_path_rotates_steps() { let mut s = session(); From 0f65f6d3f47a5b05247d7e384a6cb500c132c67b Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Thu, 12 Nov 2026 16:35:41 +0700 Subject: [PATCH 124/126] Revert "Merge pull request #6656 from dotCooCoo/fix/inspect-library-ids-whole" This reverts commit 3ed0dc64a8670453323a94d0b7c15fabb0925dea, reversing changes made to 70ba41bb2b9c3d17cb251522d6def87ae413ddf1. --- crates/engine/src/cmd/swatchlib.rs | 77 +-- crates/engine/src/file_access.rs | 14 - crates/engine/src/lib.rs | 3 - crates/engine/src/session/documents.rs | 1 - crates/engine/src/tests/library_roots.rs | 56 +- crates/testkit/src/strategies.rs | 12 +- crates/ui-egui/src/app/screenshot.rs | 9 +- crates/ui-egui/src/control.rs | 809 ++--------------------- crates/ui-egui/src/control/tests.rs | 350 ---------- crates/ui-egui/src/tests/automation.rs | 469 ------------- docs/mcp.md | 54 +- docs/target-app-parity.md | 2 +- 12 files changed, 89 insertions(+), 1767 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 8101cfff97..45320066cf 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Copy to User Defined", ["Window", "Swatch Libraries"], None, - "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}", + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", has_user_folder, copy_to_user ), @@ -368,9 +368,7 @@ impl<L: LibraryFile> Libraries<L> { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { - // The file's bytes are read: outside the request's read roots, the error names no - // path. A document's swatches (the arm below) are written from the loaded library, - // and the document is not read again. + // Outside the request's read roots: the error names no path. if !readable(&p) { return Err(bad(cmd, "the library's file is outside the read roots")); } @@ -471,34 +469,19 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } -/// The file name a concealed library or path is shown by: the last part of `path` as this platform -/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute -/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one -/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. -pub fn concealed_file_name(path: &str) -> String { - let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); - native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) -} - /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without -/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is -/// never readable under a read root. +/// roots in force. fn readable(path: &str) -> bool { - !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() + crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is -/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute -/// path counts as one the request may not read. The ids are those of the -/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers -/// to). When another id already is or shows that id, the next free -/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library -/// keeps the id it was first shown under for the rest of the session, so an id a client was given -/// finds the same library after another one with the same file name is loaded; only a library that -/// shows that id itself (one loaded later without a file under that name) moves it to the next free -/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of -/// their full ids, then `others`, in the same order. Without roots in force, none. +/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then +/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free +/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The +/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows +/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -510,7 +493,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin let mut taken = BTreeSet::new(); for ids in [session, others] { for id in ids { - let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path)); + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path)); match name { Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { @@ -519,32 +502,16 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin } } } - let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner()); - let mut kept = vec![None; hidden.len()]; - for (slot, (id, _)) in kept.iter_mut().zip(&hidden) { - if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) { - taken.insert(shown.clone()); - *slot = Some(shown.clone()); - } - } - // A new id is never one given to another library earlier in the session. - let mut given: BTreeSet<String> = assigned.values().cloned().collect(); hidden .into_iter() - .zip(kept) - .map(|((id, name), kept)| { - let shown = kept.unwrap_or_else(|| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) || given.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); - given.insert(shown.clone()); - assigned.insert(id.clone(), shown.clone()); - shown - }); + .map(|(id, name)| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); (id, shown) }) .collect() @@ -779,12 +746,8 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied}); - // As the saves with `user: true`: no `path` when the request may not read the file. - if let Some(path) = s.swatch_libraries.shown_path(&info.id) { - out["path"] = json!(path); - } - Ok(out) + let path = s.swatch_libraries.shown_path(&info.id); + Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) } /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 6ca724d810..73c8ba8efe 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,20 +215,6 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } -/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one -/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder. -pub fn written_elsewhere(path: &str) -> bool { - let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - !cfg!(windows) && foreign && current().is_some() -} - -/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, -/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, -/// `destinationFolder`)? -pub fn is_path_key(key: &str) -> bool { - matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) -} - /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b924a52e86..cd3a103a86 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,9 +151,6 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, - /// The id each loaded library outside a request's read roots has been shown under, by its own id - /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session. - shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index c5ba0410bf..887c740936 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,7 +80,6 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), - shown_library_ids: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 5ce4a3757e..30a8776b35 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,72 +214,26 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let mine = format!("loaded/{}", text(&inside)); let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine}))); assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}"); - assert_eq!(copied.get("path"), None, "{copied}"); + assert_eq!(copied["path"], Value::Null, "{copied}"); let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"}))); assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}"); for reply in [&added, &copied, &saved] { assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}"); } - // A library loaded from data under the file name keeps its id; the concealed one shown under that - // id takes the next free one, the other keeps its id, and no two rows share an id. + // A library loaded from data under the file name keeps its id; each concealed one takes the next + // free one, so no two rows share an id. let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string(); run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"})); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}"); // Copied into the user library folder, it is still found by that id, which no other library takes. confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"}))); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}"); // The person sees the folder. let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"})); assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches")))); let _ = std::fs::remove_dir_all(base); } - -/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl` -/// is shown under its whole file name. Another platform's absolute path is still named by the part -/// after its last `\`. -#[cfg(not(windows))] -#[test] -fn a_concealed_library_keeps_a_backslash_in_its_file_name() { - let base = folder("backslash-name"); - let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); - let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); - let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); - let s = Session::new(); - let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); - let _ = std::fs::remove_dir_all(base); - hidden.sort(); - let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; - want.sort(); - assert_eq!(hidden, want); -} - -/// A concealed library keeps the id a client was shown when another library with the same file name, -/// whose full id sorts before it, is loaded later: the id still finds the first library. -#[test] -fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() { - let base = folder("keeps-id"); - let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); - let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches")); - let mut s = Session::new(); - run(&mut s, "file.new", json!({"width": 100, "height": 100})); - (SWATCHES.add)(&mut s, "First"); - run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"})); - (SWATCHES.add)(&mut s, "Second"); - run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"})); - let shown = "loaded/Kit.vcswatches"; - let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone(); - let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); - assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}"); - run(&mut s, "swatch.library.load", json!({"path": text(&later)})); - let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); - let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}"); - let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))})); - let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown}))); - assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}"); - let _ = std::fs::remove_dir_all(base); -} diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 372152870d..10b0608001 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,17 +502,15 @@ pub fn param_keys(doc: &str) -> Vec<String> { out } -/// Parameter-name keys that name a file or folder a command may write -/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could -/// write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write: fuzzing these with +/// arbitrary strings could write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - vectorcraft_engine::file_access::is_path_key(k) + matches!(k, "path" | "diffPath") || is_folder_key(k) } -/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in -/// `Folder` (`destinationFolder`). +/// Path keys that name a folder a command creates or writes files into. pub fn is_folder_key(k: &str) -> bool { - k == "folder" || k.ends_with("Folder") + matches!(k, "folder" | "destinationFolder") } /// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index c34d022e50..ce97fc747f 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -157,12 +157,9 @@ impl VectorcraftApp { let pending = self.pending_screenshots.remove(index); self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); let roots = self.automation_roots.clone(); - let reply = file_access::confine(roots.as_ref(), || { - let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data); - control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path})); - reply - }); - let _ = pending.reply.send(reply); + let _ = pending + .reply + .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); } Err(e) => { let error = format!("the screenshot could not be used: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c758477b2e..11edb6de6e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); leave_out_unreadable_paths(fields); } - json!({ + let mut inspected = json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), @@ -307,7 +307,10 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }) + }); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut inspected, &ids); + inspected } /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel @@ -319,150 +322,33 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel -/// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`], the workspaces' layouts and the keys of shortcut overrides (`panel:<panel id>`, -/// saved and in an open Keyboard Shortcuts dialog), and by either in the `library` and `panel` params of -/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an -/// action's name) is not read. The session's loaded libraries whose file the request may not read are -/// hidden too. The ids are numbered as the library commands number them -/// ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so that two libraries stay two and -/// `ui.inspect`, `ui.docking` and the library commands show a library under the same id. -fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { - let mut rest = std::collections::BTreeSet::new(); +/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a +/// workspace's name) is not read. The ids are numbered with the session's loaded libraries as the +/// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so +/// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library +/// under the same id. +fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { + let mut ids = std::collections::BTreeSet::new(); let ui = serde_json::to_value(&app.ui).unwrap_or_default(); for field in PANEL_FIELDS { - collect_library_panel_ids(&ui[field], &mut rest); + collect_library_panel_ids(&ui[field], &mut ids); } - // A shortcut for showing a panel is keyed `panel:<id>`, saved and in an open Keyboard Shortcuts dialog. - for overrides in [&ui["shortcut_overrides"], &ui["dialog"]["fields"]["overrides"]] { - for panel in overrides.as_object().into_iter().flat_map(|o| o.keys()).filter_map(|key| key.strip_prefix("panel:")) { - collect_library_panel_ids(&json!(panel), &mut rest); - } - } - collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { - collect_library_panel_ids(layout, &mut rest); + collect_library_panel_ids(layout, &mut ids); } } - collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest); - let mut ids = rest; + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { add_library_id(id, &mut ids); } let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned()); - // The other loaded library ids the session and the UI state hold. - let s = &app.session; - let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); - let readable_ids = loaded - .map(|info| info.id.clone()) - .chain(ids) - .filter(|id| id.len() > "loaded/".len() && id.starts_with("loaded/") && !hidden.iter().any(|(h, _)| h == id)) - .collect(); + // Only the ids the UI state refers to are replaced in the reply. + hidden.retain(|(id, _)| ids.contains(id)); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - Hiding { hidden, readable: readable_ids, sent: Vec::new() } -} - -/// What a reply hides ([`hide_library_ids`]). -#[derive(Debug, Default)] -struct Hiding { - /// The ids of the loaded libraries the request may not read, longest first, each with the id - /// shown instead ([`unreadable_library_ids`]). - hidden: Vec<(String, String)>, - /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or - /// path that begins one of them stays ([`begins_known_id`]). - readable: std::collections::BTreeSet<String>, - /// The paths outside the read roots that the request sent ([`sent_paths`]), each as a hidden - /// library id with the id shown. They are matched after the hidden libraries. - sent: Vec<(String, String)>, -} - -impl Hiding { - /// Adds the hidden and readable ids of `other` that this one does not hold. - fn merge(&mut self, other: Hiding) { - self.add_hidden(other.hidden); - self.readable.extend(other.readable); - } - - /// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown. - fn add_hidden(&mut self, pairs: Vec<(String, String)>) { - for pair in pairs { - if !self.hidden.iter().any(|(id, _)| *id == pair.0) { - self.hidden.push(pair); - } - } - self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - } - - /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds - /// for the same path (written with either separator when it is a Windows path - /// ([`splits_at_backslash`]), relative or as the absolute path it reads as ([`absolute_form`]), and - /// in any case of ASCII letters with `fold`) is shown as the sent path is. - fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { - let key = |id: &str| { - let path = id.strip_prefix("loaded/").unwrap_or(id); - let id = absolute_form(path).unwrap_or_else(|| path.to_string()); - let windows = splits_at_backslash(&id); - let id = if windows { id.replace('\\', "/") } else { id }; - (windows, if fold { id.to_ascii_lowercase() } else { id }) - }; - let sent: Vec<(bool, String)> = pairs.iter().map(|(id, _)| key(id)).collect(); - for (id, shown) in &mut self.hidden { - if let Some(at) = sent.iter().position(|k| *k == key(id)) { - shown.clone_from(&pairs[at].1); - } - } - for pair in pairs { - if !self.sent.iter().any(|(id, _)| *id == pair.0) { - self.sent.push(pair); - } - } - } -} - -/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths -/// ([`unreadable_path_like`]) or held by a path param -/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id -/// (`loaded/<path>`) with `loaded/<file name>` shown. A reply hides them as it hides a hidden library's -/// path, with that file name also for a hidden library shown as `loaded/<file name> (2)`, so that it -/// reads the same whether or not a library was loaded from one of them. -fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { - collect_sent_paths(v, false, out); -} - -/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set. -fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) { - fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) { - if !unreadable_path_like(s) && !(path_param && !readable_value(s)) { - return; - } - // A URL that names another host as the path after its scheme (`//host/share/…`). - let path = match file_url_path(s) { - Some(Some(path)) => path, - Some(None) => percent_decoded(&s[5..]), - None => s.to_string(), - }; - let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path); - if name == path { - return; - } - let pair = (format!("loaded/{path}"), format!("loaded/{name}")); - if !out.contains(&pair) { - out.push(pair); - } - } - match v { - Value::String(s) => add(s, path_param, out), - Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)), - Value::Object(fields) => { - for (key, item) in fields { - add(key, false, out); - collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); - } - } - _ => {} - } + hidden } /// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`). @@ -493,358 +379,33 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str } } -/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name -/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id -/// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's -/// report and a recorded step. An occurrence that begins another loaded library's id stays -/// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On -/// Windows the ids and paths are matched in any case of ASCII letters. -fn hide_library_ids(v: &mut Value, hiding: &Hiding) { - let pairs = hidden_text(hiding); - if pairs.iter().all(Vec::is_empty) { +/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's +/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. +fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { + if ids.is_empty() { return; } - replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); -} - -/// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of -/// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with -/// the id shown, and the path in it with the file name shown, a Windows path ([`splits_at_backslash`]) -/// written with either separator too, and as `{:?}` quotes it (each `\`, quote and control character -/// escaped). A relative path is also replaced as the absolute path the file access check reads it as, -/// against the working folder. -fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { - vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] -} - -/// Is `path` a Windows path, whose parts a `\` separates as well as a `/`: any path on Windows, and -/// elsewhere one that starts with a drive (`C:`) or a `\`? Outside Windows, a `\` in any other path is -/// part of a name. -fn splits_at_backslash(path: &str) -> bool { - cfg!(windows) || path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()) -} - -/// The absolute path that `path`, a relative path (one that does not start with a separator, a drive -/// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other -/// path. -fn absolute_form(path: &str) -> Option<String> { - let relative = !path.starts_with(['/', '\\', '~']) && !matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - relative.then(|| std::path::absolute(path).ok()).flatten().map(|p| p.to_string_lossy().into_owned()) -} - -/// The text [`hidden_text`] replaces for the ids of `ids`, longest first. -fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { - let quoted = |s: &str| { - let q = format!("{s:?}"); - q[1..q.len() - 1].to_string() + let hide = |s: &mut String| { + for (id, shown) in ids { + if s.contains(id.as_str()) { + *s = s.replace(id.as_str(), shown); + } + } }; - let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in ids { - let path = &id["loaded/".len()..]; - let name = shown.strip_prefix("loaded/").unwrap_or(shown); - for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { - // A Windows path is also written with the other separator; outside Windows, a native - // path's `\` is part of a name, so that path is written only as it is. - let mut spellings = vec![path.clone()]; - if splits_at_backslash(&path) { - spellings.extend([path.replace('\\', "/"), path.replace('/', "\\")].into_iter().filter(|p| splits_at_backslash(p))); - } - let debug: Vec<_> = spellings.iter().map(|p| (quoted(p), quoted(name))).collect(); - let plain = spellings.into_iter().map(|p| (p, name.to_string())); - for (path, name) in plain.chain(debug) { - for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { - if !pairs.iter().any(|(f, _)| *f == from) { - pairs.push((from, to)); - } - } - } - } - } - pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len())); - pairs -} - -/// What [`replace_in_value`] writes: the groups of text to replace ([`hidden_text`]), what the reply -/// hides, and whether to match in any case of ASCII letters. -struct Rewrite<'a> { - pairs: Vec<Vec<(String, String)>>, - hiding: &'a Hiding, - fold: bool, -} - -/// The keys of encoded file data in a reply. -const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; - -/// Is `s` written in the base64 alphabet, padded to a multiple of 4? -fn is_base64(s: &str) -> bool { - let body = s.trim_end_matches('='); - s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') -} - -/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as -/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays -/// as it is. -fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { - Value::String(s) => { - if let Some(replaced) = replace_text(s, r) { - *s = replaced; - } - } - Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), + Value::String(s) => hide(s), + Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), Value::Object(fields) => { - for (key, mut item) in std::mem::take(fields) { - if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { - fields.insert(key, item); - continue; - } - replace_in_value(&mut item, r); - fields.insert(replace_text(&key, r).unwrap_or(key), item); + for (mut key, mut item) in std::mem::take(fields) { + hide(&mut key); + hide_library_ids(&mut item, ids); + fields.insert(key, item); } } _ => {} } } -/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters -/// with `fold`. -fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { - if needle.is_empty() { - return Vec::new(); - } - if fold { - let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase()); - text.match_indices(needle.as_str()).map(|(at, _)| at).collect() - } else { - text.match_indices(needle).map(|(at, _)| at).collect() - } -} - -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence -/// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written -/// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence -/// is found in `s` as it was given, the hidden libraries' text before the sent paths' text, each group -/// longest `from` first, and one that overlaps an occurrence already found, or a loaded library's id -/// that one began, stays: a replacement's text is not searched again. With the rewrite's `fold`, the -/// ids and paths of a group as written are found first, so that two libraries whose paths differ only -/// in letter case keep their own ids, and then the ones written in another case. `None` when nothing -/// changed. -fn replace_text(s: &str, r: &Rewrite) -> Option<String> { - let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; - let mut found = Found::default(); - for group in &r.pairs { - for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { - for (from, to) in group { - if let Some((text, spans)) = &decoded { - found.claim(text, Some(spans.as_slice()), from, to, fold, r); - } - found.claim(s, None, from, to, fold, r); - } - } - } - if found.cuts.is_empty() { - return None; - } - let mut cuts = found.cuts; - cuts.sort_unstable_by_key(|&(start, _, _)| start); - let mut out = String::with_capacity(s.len()); - let mut copied = 0; - for (start, stop, to) in cuts { - out.push_str(&s[copied..start]); - out.push_str(to); - copied = stop; - } - out.push_str(&s[copied..]); - Some(out) -} - -/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has -/// taken: the ones it replaces and the loaded library ids it keeps. -#[derive(Default)] -struct Found<'a> { - cuts: Vec<(usize, usize, &'a str)>, - taken: Vec<(usize, usize)>, -} - -impl<'a> Found<'a> { - /// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`, - /// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans` - /// gives the range of the original text each byte of a decoded `text` was read from. - fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) { - let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1)); - for at in occurrences(text, from, fold) { - let end = at + from.len(); - let (start, stop) = span(at, end); - if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) { - continue; - } - match begins_known_id(text, at, end, r) { - Some(len) => self.taken.push(span(at, at + len)), - None => { - self.taken.push((start, stop)); - self.cuts.push((start, stop, to)); - } - } - } - } -} - -/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` -/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. -fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { - let bytes = text.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut spans = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - let hex = bytes - .get(i + 1..i + 3) - .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit)) - .and_then(|h| std::str::from_utf8(h).ok()) - .and_then(|h| u8::from_str_radix(h, 16).ok()); - match hex { - Some(v) => { - out.push(v); - spans.push((i, i + 3)); - i += 3; - } - None => { - out.push(bytes[i]); - spans.push((i, i + 1)); - i += 1; - } - } - } - let mut decoded = String::with_capacity(out.len()); - let mut decoded_spans = Vec::with_capacity(spans.len()); - let mut at = 0; - for chunk in out.utf8_chunks() { - decoded.push_str(chunk.valid()); - decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?); - at += chunk.valid().len(); - for _ in chunk.invalid() { - decoded.push(char::REPLACEMENT_CHARACTER); - let span = *spans.get(at)?; - decoded_spans.extend([span; 3]); - at += 1; - } - } - Some((decoded, decoded_spans)) -} - -/// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at -/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by -/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and -/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and -/// `../data/icon.svg` do. -fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { - let absolute = - from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - let mut before = &text[..at]; - while let Some(head) = before.strip_suffix(['/', '\\']) { - let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break }; - before = rest; - if !before.ends_with(['/', '\\']) { - break; - } - } - !absolute && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) -} - -/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, -/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it. -fn continues_path(rest: &str) -> bool { - rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) -} - -/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written -/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops -/// continuing a path ([`continues_path`])? The length of that id as written, when it does. -/// A readable library's id that begins with a hidden one stays, also with a space after the hidden -/// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. -fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usize> { - let rest = &text[at..]; - let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; - let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); - let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); - r.hiding.readable.iter().filter(|known| !dotted(known)).find_map(|known| { - let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; - let begins = id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]); - begins.then_some(id.len()) - }) -} - -/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a -/// workspace's name. [`swap_library_ids`] leaves it as written. -const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"]; - -/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]), -/// in strings and keys, except in the [`TEXT_FIELDS`]. -fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) { - if pairs.is_empty() { - return; - } - match v { - Value::String(s) => { - if let Some(swapped) = swap_whole_ids(s, pairs) { - *s = swapped; - } - } - Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), - Value::Object(fields) => { - for (key, mut item) in std::mem::take(fields) { - if TEXT_FIELDS.contains(&key.as_str()) { - fields.insert(key, item); - continue; - } - swap_library_ids(&mut item, pairs); - fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item); - } - } - _ => {} - } -} - -/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string, -/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends -/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed. -fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option<String> { - let mut out: Option<String> = None; - for (from, to) in pairs { - let text = out.as_deref().unwrap_or(s); - if !text.contains(from) { - continue; - } - let mut next = String::with_capacity(text.len()); - let mut copied = 0; - for (at, _) in text.match_indices(from) { - let end = at + from.len(); - let starts = at == 0 || text[..at].ends_with([':', '"', '`']); - let ends = end == text.len() || text[end..].starts_with(['"', '`']); - if at >= copied && starts && ends { - next.push_str(&text[copied..at]); - next.push_str(to); - copied = end; - } - } - if copied > 0 { - next.push_str(&text[copied..]); - out = Some(next); - } - } - out -} - -/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)? -fn names_a_loaded_library(v: &Value) -> bool { - match v { - Value::String(s) => s.contains("loaded/"), - Value::Array(items) => items.iter().any(names_a_loaded_library), - Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)), - _ => false, - } -} - /// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; @@ -853,146 +414,14 @@ const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; /// May the request being served read `path`? Under an automation read root (a control client's /// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent -/// file or a listed script outside it. Another platform's absolute path -/// ([`vectorcraft_engine::file_access::written_elsewhere`]) is never readable under a read root. +/// file or a listed script outside it. fn readable(path: &str) -> bool { - !vectorcraft_engine::file_access::written_elsewhere(path) && vectorcraft_engine::file_access::check_read(path).is_ok() -} - -/// Is `s` a path the request may not read, written as an absolute path (either separator, with or -/// without a drive) or a `file:` URL ([`file_url_path`])? Under a read root, a `file:` URL that names -/// another host is never readable. -fn unreadable_absolute(s: &str) -> bool { - match file_url_path(s) { - Some(path) => !readable_url_path(path), - None => { - let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); - absolute && !readable(s) - } - } -} - -/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL -/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a -/// separator? It is checked as the file access check reads it, relative to the working folder. Other -/// text with a separator (`a/b`) stays. -fn unreadable_path_like(s: &str) -> bool { - if unreadable_absolute(s) { - return true; - } - let t = s.trim_start(); - let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p)); - relative && !readable_value(s) -} - -/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? -fn readable_value(s: &str) -> bool { - match file_url_path(s) { - Some(path) => readable_url_path(path), - None => readable(s), - } -} - -/// May the request read the local path a `file:` URL names ([`file_url_path`])? A URL that names -/// another host may be read only without a read root in force. -fn readable_url_path(path: Option<String>) -> bool { - match path { - Some(path) => readable(&path), - None => vectorcraft_engine::file_access::current().is_none(), - } -} - -/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` -/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). -/// `file:///C:/x` names `C:/x`. -fn file_url_path(s: &str) -> Option<Option<String>> { - let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?; - let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) }; - let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; - if !rest.starts_with('/') { - return Some(None); - } - let path = match rest.as_bytes() { - [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..], - _ => rest, - }; - Some(Some(percent_decoded(path))) -} - -/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href. -fn percent_decoded(s: &str) -> String { - if !s.contains('%') { - return s.to_string(); - } - let mut out = Vec::with_capacity(s.len()); - let mut rest = s.as_bytes(); - while let Some((&b, tail)) = rest.split_first() { - let hex = tail - .get(..2) - .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) - .and_then(|h| std::str::from_utf8(h).ok()) - .and_then(|h| u8::from_str_radix(h, 16).ok()); - match hex { - Some(v) if b == b'%' => { - out.push(v); - rest = tail.get(2..).unwrap_or_default(); - } - _ => { - out.push(b); - rest = tail; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - -/// The params of each step of each action of action set `set` (`ui.action_sets`). -fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<String, Value>> { - let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); - let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); - steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) -} - -/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the -/// paths the request may not read: the value of a path param -/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` -/// and the like), each path of a list under such a param, and under any other param, as a key too, a -/// string written as a path ([`unreadable_path_like`]). -fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { - use vectorcraft_engine::file_access::is_path_key; - params.retain(|key, v| { - !unreadable_path_like(key) - && match v.as_str() { - Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)), - None => true, - } - }); - for (key, v) in params.iter_mut() { - if let Value::Array(paths) = v - && is_path_key(key) - { - paths.retain(|p| p.as_str().is_none_or(readable_value)); - } - leave_out_unreadable_values(v); - } -} - -/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the -/// request may not read ([`unreadable_path_like`]) taken out of each list. -fn leave_out_unreadable_values(v: &mut Value) { - match v { - Value::Object(fields) => leave_out_unreadable_params(fields), - Value::Array(items) => { - items.retain(|item| !item.as_str().is_some_and(unreadable_path_like)); - items.iter_mut().for_each(leave_out_unreadable_values); - } - _ => {} - } + vectorcraft_engine::file_access::check_read(path).is_ok() } /// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, -/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths -/// in recorded actions' steps outside the read root. The UI state itself is not changed. +/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside +/// the read root. The UI state itself is not changed. fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -1004,9 +433,6 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { } } } - for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { - leave_out_unreadable_params(step); - } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); } @@ -1022,9 +448,8 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { /// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) { for key in PREF_FOLDERS { - let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; - let path = written.trim(); - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { + let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { continue; } prefs.remove(key); @@ -1069,142 +494,7 @@ fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { } } -/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, -/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking -/// commands' destinations (`window.panel.move {anchor, before}`, `ui.dock {target}`, -/// `window.panel.float {onto, above, below}`) and a list of panels. Only a whole id is translated -/// ([`swap_whole_ids`]), so other text in these params stays. -const LIBRARY_PARAMS: [&str; 9] = ["panel", "library", "anchor", "target", "before", "onto", "above", "below", "panels"]; - -/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is -/// translated. -const DOCKING_ACTION: &str = "action"; - -/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` -/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id -/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each -/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, -/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The -/// reply also shows each path outside the read root that the request sent by its file name, whether it -/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path -/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent -/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id -/// or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { - if vectorcraft_engine::file_access::current().is_none() { - return dispatch(app, ctx, req); - } - // Pointer, key and text input does not load, close or rename a library: the libraries hidden after - // it ran are the ones hidden before, so frequent input does not read the UI state twice. - let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) }; - let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); - let mut params = req.params.clone(); - let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown); - let ran = vectorcraft_engine::guard::catch_panic(|| { - if swapped { - let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - dispatch(app, ctx, &request) - } else { - dispatch(app, ctx, req) - } - }); - let outcome = match ran { - Ok(outcome) => outcome, - Err(msg) => { - app.picks.recover(); - app.session.set_replaying(false); - err(format!("internal error: {msg} (please report this bug)")) - } - }; - let Outcome::Done(mut reply) = outcome else { return outcome }; - hide_in_reply(app, &mut reply, &req.params, before); - Outcome::Done(reply) -} - -/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that -/// `sent`, the request's params, holds ([`handle`]). -fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { - if !holds_a_separator(reply) { - return; - } - let mut hiding = unreadable_library_ids(app); - hiding.merge(before); - let mut paths = Vec::new(); - sent_paths(sent, &mut paths); - hiding.set_hidden(paths, cfg!(windows)); - hide_library_ids(reply, &hiding); -} - -/// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a -/// reply, with `sent` as the request's params. Without a read root in force it leaves the reply as -/// it is. -pub(crate) fn hide_in_deferred_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value) { - if vectorcraft_engine::file_access::current().is_some() { - hide_in_reply(app, reply, sent, Hiding::default()); - } -} - -/// The control methods that send synthetic input. -const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"]; - -/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`? -fn holds_a_separator(v: &Value) -> bool { - let has = |s: &str| s.contains(['/', '\\', '%']); - match v { - Value::String(s) => has(s), - Value::Array(items) => items.iter().any(holds_a_separator), - Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)), - _ => false, - } -} - -/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its -/// `to` id → whether one was. -fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool { - match params { - Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped), - Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| { - let this = match item { - Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(), - Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| { - let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some(); - this | swapped - }), - Value::Object(_) if key == DOCKING_ACTION => { - let before = item.clone(); - swap_library_ids(item, pairs); - *item != before - } - _ => swap_library_params(item, pairs), - }; - this | swapped - }), - _ => false, - } -} - -/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth: -/// a library's id or its panel's id. -fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet<String>) { - match v { - Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)), - Value::Object(fields) => { - for (key, item) in fields { - if !LIBRARY_PARAMS.contains(&key.as_str()) { - collect_library_params(item, ids); - continue; - } - for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) { - add_library_id(s, ids); - collect_library_panel_ids(&json!(s), ids); - } - } - } - _ => {} - } -} - -fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { @@ -1221,19 +511,12 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), - // A request that stops with a panic quoting the status text, after clearing the Color Guide's - // limit with `forget`. - #[cfg(test)] - "test.panic" => { - if p.get("forget").and_then(Value::as_bool) == Some(true) { - app.ui.color_guide_limit.clear(); - } - panic!("failed on {}", app.ui.status) + "ui.docking" => { + let mut docking = crate::panel_docking::inspect(app, ctx); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut docking, &ids); + ok(docking) } - // A request that succeeds with the status text. - #[cfg(test)] - "test.status" => ok(json!(app.ui.status)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0b19350f6f..1763fd679a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,356 +12,6 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } -/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in -/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`. -#[test] -fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { - let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); - let id = format!("loaded/{path}"); - let hidden = hiding(&[(&id, "loaded/b.svg")], &[]); - let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); - hide_under_a_root("named-reply", &mut reply, &hidden); - assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); -} - -/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids. -fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { - let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); - hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() } -} - -/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. -fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { - let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); - let _ = std::fs::remove_dir_all(root); -} - -/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or -/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 -/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. -#[test] -fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { - let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); - let data = "QUJ+assets/iconREVGxQQ=="; - let mut reply = json!({"ok": true, "result": { - "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], - "status": "from assets/icon", - "dataBase64": {"note": "from assets/icon"}, - "pngBase64": ["from assets/icon"], - "otherBase64": data, - }}); - hide_under_a_root("base64", &mut reply, &hidden); - let files = &reply["result"]["files"][0]; - assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); - assert_eq!(files["pngBase64"], "icon.png", "{reply}"); - assert_eq!(reply["result"]["status"], "from icon", "{reply}"); - assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); - assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); - assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}"); -} - -/// A hidden path followed by more text stays only when the longer text is another loaded library's -/// id: text after the path that the request may read, a `..` part and the part of the path before a -/// space do not keep it. -#[test] -fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { - let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); - let root = base.join("in"); - std::fs::create_dir_all(&root).unwrap(); - let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); - let other = base.join("outside").join("b.svg").to_string_lossy().into_owned(); - let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR); - let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]); - let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR); - let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(base); - assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); - // A readable library's id with a space after the hidden part stays whole. - // A readable id with a `..` part does not keep the hidden part. - let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]); - let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"}); - hide_under_a_root("space-continuation", &mut reply, &hidden); - assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}"); -} - -/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden -/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. -#[test] -fn a_reply_hides_a_path_in_any_case_and_escaped() { - let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); - let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); - let pairs = super::hidden_text(&hidden); - let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; - super::replace_in_value(&mut reply, &rewrite); - assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); - // Two libraries whose paths differ only in letter case keep their own ids. - let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); - let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true }); - assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); - // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. - let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); - let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true }); - assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); - let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); - hide_under_a_root("escaped", &mut reply, &hidden); - assert_eq!(reply["error"], "file:///b.svg", "{reply}"); - let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); - let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); - hide_under_a_root("escaped-mixed", &mut reply, &hidden); - assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); -} - -/// A path a failed request sent takes the place of a hidden library's id for the same path, written -/// with the other separator, and in another letter case when matching in any case (as on Windows). -#[test] -fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { - let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); - hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); - let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect(); - assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}"); - assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]); - hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false); - assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}"); - // A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's - // file name is shown for both, not the library's numbered id. - let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]); - let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); - hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false); - let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")}); - hide_under_a_root("sent-absolute", &mut reply, &hidden); - assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); -} - -/// Outside Windows, a `\` in a native path is part of a name: a sent path that differs from a hidden -/// library's path only by `/` in place of that `\` names another file, so the library keeps its id -/// and a reply does not read either path as a spelling of the other. -#[cfg(not(windows))] -#[test] -fn a_backslash_in_a_native_path_keeps_two_paths_apart() { - let mut hidden = hiding(&[(r"loaded//out/weird\name.svg", r"loaded/weird\name.svg")], &[]); - hidden.set_hidden(vec![("loaded//out/weird/name.svg".to_string(), "loaded/name.svg".to_string())], false); - assert_eq!(hidden.hidden[0].1, r"loaded/weird\name.svg", "{hidden:?}"); - let mut reply = json!({"error": r"no /out/weird\name.svg, /out/weird/name.svg or \out\weird\name.svg"}); - hide_under_a_root("native-backslash", &mut reply, &hidden); - assert_eq!(reply["error"], r"no weird\name.svg, name.svg or \out\weird\name.svg", "{reply}"); -} - -/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path -/// from being hidden: the hidden libraries are matched first. -#[test] -fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() { - let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]); - hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false); - let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"}); - hide_under_a_root("sent-overlap", &mut reply, &hidden); - assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}"); -} - -/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from -/// the working folder. -#[test] -fn a_reply_hides_a_hidden_relative_path_written_as_absolute() { - let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); - let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); - let mut reply = json!({"error": format!("can't read {absolute}")}); - hide_under_a_root("relative-absolute", &mut reply, &hidden); - assert_eq!(reply["error"], "can't read icon.svg", "{reply}"); -} - -/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names -/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden -/// by their file names; plain text and a path inside the root are not. -#[test] -fn a_request_sends_paths_that_an_error_hides() { - let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); - let root = base.join("in"); - std::fs::create_dir_all(&root).unwrap(); - let inside = root.join("a.svg").to_string_lossy().into_owned(); - let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); - let mut sent = Vec::new(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); - let _ = std::fs::remove_dir_all(base); - let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); - let want = [ - pair("outside/h.svg", "h.svg"), - pair("/y/c d.svg", "c d.svg"), - pair("~/e.svg", "e.svg"), - pair("../f.svg", "f.svg"), - pair("//host/g.svg", "g.svg"), - pair("/x/my lib/b.svg", "b.svg"), - ]; - assert_eq!(sent, want); -} - -/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a -/// relative name and a path inside the root are not. -#[test] -fn an_absolute_path_outside_the_read_root_is_unreadable() { - let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); - std::fs::create_dir_all(base.join("in")).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); - let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || { - assert!(super::unreadable_absolute("/outside/root/x")); - assert!(super::unreadable_absolute("file:///outside/root/x")); - assert!(super::unreadable_absolute("FILE:///outside/root/x")); - assert!(super::unreadable_absolute("file://other-host/share/x")); - let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) }; - assert!(!super::unreadable_absolute(&url), "{url}"); - assert!(super::readable_value(&url), "{url}"); - let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1); - assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}"); - assert!(super::unreadable_absolute("file:///outside%2Froot/x")); - assert!(!super::unreadable_absolute("")); - assert!(!super::unreadable_absolute("keep")); - assert!(!super::unreadable_absolute(&inside)); - }); - let _ = std::fs::remove_dir_all(base); -} - -/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps -/// it. -#[test] -fn a_url_naming_another_host_stays_without_a_read_root() { - assert!(!super::unreadable_absolute("file://host/share/a.svg")); - assert!(super::readable_value("file://host/share/a.svg")); - let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}); - let fields = params.as_object_mut().unwrap(); - super::leave_out_unreadable_params(fields); - assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]})); -} - -/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped. -#[test] -fn a_reply_hides_a_quoted_path_with_escaped_quotes() { - let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]); - let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")}); - hide_under_a_root("quoted", &mut reply, &hidden); - assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}"); -} - -/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another -/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden -/// path it begins with. -#[test] -fn a_reply_replaces_each_hidden_text_once() { - let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]); - let mut reply = json!({"error": "no loaded//b.svg"}); - hide_under_a_root("root-file", &mut reply, &hidden); - assert_eq!(reply["error"], "no loaded/b.svg", "{reply}"); - let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]); - let mut reply = json!({"error": "file:///art/in%20work/a.svg"}); - hide_under_a_root("escaped-readable", &mut reply, &hidden); - assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); -} - -/// A library saved with another platform's absolute path is hidden, also when the working folder is -/// inside the read root. -#[test] -fn a_library_saved_with_another_platforms_path_is_hidden() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); - let here = std::env::current_dir().unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); - let hiding = vectorcraft_engine::file_access::confine(roots.as_ref(), || super::unreadable_library_ids(&app)); - assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); -} - -/// Another platform's absolute path is outside the read root, also when the working folder is inside -/// it: a request that sends one sees it by its file name. -#[test] -fn another_platforms_absolute_path_is_unreadable() { - let here = std::env::current_dir().unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); - let path = r"C:\Users\someone\private.svg"; - let mut sent = Vec::new(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || { - assert!(!super::readable(path) && super::unreadable_absolute(path)); - super::sent_paths(&json!({"list": [path]}), &mut sent); - }); - assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); -} - -/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and -/// stays where it ends a longer path, also one with a `.` or `..` part before it. -#[test] -fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { - let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); - let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); - hide_under_a_root("relative", &mut reply, &hidden); - assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); - let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"}); - hide_under_a_root("relative-dots", &mut reply, &hidden); - assert_eq!( - reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'", - "{reply}" - ); -} - -/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its -/// whole file name. -#[cfg(not(windows))] -#[test] -fn a_sent_path_keeps_a_backslash_in_its_file_name() { - let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - let mut sent = Vec::new(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); - let _ = std::fs::remove_dir_all(root); - assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); -} - -/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system -/// (`/b.svg`) as `loaded/b.svg`, with its separator. -#[test] -fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - let panel = "library:swatches:loaded//b.svg".to_string(); - app.ui.color_guide_limit = "loaded//b.svg".to_string(); - app.ui.open_panel = Some(panel); - let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { - (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) - }); - let _ = std::fs::remove_dir_all(root); - assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); - assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); - assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); -} - -/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded -/// as in one that failed. -#[test] -fn a_sent_path_reads_the_same_whatever_the_outcome() { - let app = VectorcraftApp::new(Session::new(), Services::default()); - let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - let sent = json!({"path": "/outside/b.svg"}); - let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}}); - let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"}); - vectorcraft_engine::file_access::confine(roots.as_ref(), || { - super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default()); - super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default()); - }); - let _ = std::fs::remove_dir_all(root); - assert_eq!(done["result"]["path"], "b.svg", "{done}"); - assert_eq!(failed["error"], "can't read b.svg", "{failed}"); -} - #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 341a755bef..26406a4992 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -446,341 +446,6 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } -/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before -/// it is a relative path, outside the root, and is left out. -#[test] -fn ui_inspect_checks_a_folder_preference_as_written() { - let (mut app, tx, base) = confined("inspect-folder-space"); - let inside = s(base.join("in")); - app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); - assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); - assert!(!prefs.contains_key("templatesFolder"), "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the -/// command palette query and a script's report show the id and file name shown, and the path appears -/// nowhere in the reply. -#[test] -fn ui_inspect_hides_a_hidden_library_path_in_every_string() { - let (mut app, tx, base) = confined("inspect-status-id"); - let path = s(base.join("outside/b.svg")); - let theirs = format!("loaded/{path}"); - app.ui.color_guide_limit = theirs.clone(); - app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); - app.ui.palette_query = format!("\"{theirs}\""); - let source = format!("vc.log({})", json!(format!("read {path}."))); - app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); - for _ in 0..10_000 { - if app.session.script_pump().is_some() { - break; - } - } - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); - assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); - assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); - assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); - let outside = json!(s(base.join("outside"))).to_string(); - assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that -/// library's id wherever the reply holds it, also between spaces and brackets and where a character -/// that continues a path follows it. -#[test] -fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { - let (mut app, tx, base) = confined("named-hidden-reply"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); - app.ui.color_guide_limit = theirs.clone(); - app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); - let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); - assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); - let outside = json!(s(base.join("outside"))).to_string(); - assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, -/// and its path appears nowhere in the reply. -#[test] -fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { - let (mut app, tx, base) = confined("named-hidden-error"); - app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); - assert_eq!(r["ok"], false, "{r}"); - assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); - let outside = json!(s(base.join("outside"))).to_string(); - assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path -/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside -/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. -#[test] -fn ui_inspect_leaves_out_recorded_paths_under_any_key() { - let (mut app, tx, base) = confined("inspect-action-keys"); - let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); - let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; - let (url, url_in) = (to_url(&b), to_url(&a)); - let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ - "name": "set", - "actions": [{"name": "files", "steps": [ - ["document.compare", {"diffPath": b, "target": a}], - ["file.open", {"file": b}], - ["type.fonts", {"fontFiles": [a, b, url_in]}], - ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], - ["x.z", {"list": [b, "keep"]}], - ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], - ["x.v", {"file": url_in}], - ]}], - })) - .unwrap(); - app.ui.action_sets = vec![set]; - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; - let want = json!([ - ["document.compare", {"target": a}], - ["file.open", {}], - ["type.fonts", {"fontFiles": [a, url_in]}], - ["x.y", {"other": "keep"}], - ["x.z", {"list": ["keep"]}], - ["x.w", {"other": "keep"}], - ["x.v", {"file": url_in}], - ]); - assert_eq!(steps, &want, "{r}"); - let outside = json!(s(base.join("outside"))).to_string(); - assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action -/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. -#[test] -fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { - let (mut app, tx, base) = confined("inspect-action-steps"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); - let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ - "name": "set", - "actions": [{"name": theirs, "steps": [ - ["swatch.library.add", {"library": theirs}], - ["window.panel", {"panel": format!("library:swatches:{theirs}")}], - ]}], - })) - .unwrap(); - app.ui.action_sets = vec![set]; - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; - assert_eq!(action["name"], "loaded/b.svg", "{r}"); - assert_eq!( - action["steps"], - json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), - "{r}" - ); - let _ = std::fs::remove_dir_all(base); -} - -/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending -/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones -/// inside it. -#[test] -fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { - let (mut app, tx, base) = confined("inspect-action-paths"); - let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); - let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ - "name": "set", - "actions": [{"name": "files", "steps": [ - ["file.open", {"path": a}], - ["file.open", {"path": b}], - ["file.place", {"paths": [a, b]}], - ["batch.run", {"folder": s(base.join("outside"))}], - ["action.batch", {"destinationFolder": s(base.join("outside"))}], - ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], - ]}], - })) - .unwrap(); - app.ui.action_sets = vec![set]; - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; - let want = json!([ - ["file.open", {"path": a}], - ["file.open", {}], - ["file.place", {"paths": [a]}], - ["batch.run", {}], - ["action.batch", {}], - ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], - ]); - assert_eq!(steps, &want, "{r}"); - assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); - let _ = std::fs::remove_dir_all(base); -} - -/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with -/// either separator, also one saved on another platform. -#[test] -fn ui_inspect_hides_a_library_path_with_either_separator() { - let (mut app, tx, base) = confined("inspect-separators"); - app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); - app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// A hidden library does not take the id of a library loaded without a path under the same file -/// name: it shows as `loaded/<file name> (2)`, and a request that names the other library reaches it. -#[test] -fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { - let (mut app, tx, base) = confined("inspect-loaded-id"); - let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); - let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); - assert_eq!(loaded["library"], "loaded/b.svg"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); - app.ui.color_guide_limit = theirs; - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// Hidden libraries with the same file name that are shown for the first time in the same reply are -/// numbered in the order of their paths, as the library commands number them. -#[test] -fn hidden_libraries_are_numbered_in_the_order_of_their_paths() { - let (mut app, tx, base) = confined("hidden-load-order"); - let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; - for dir in ["z", "a"] { - std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); - let path = base.join("outside").join(dir).join("b.gpl"); - std::fs::write(&path, gpl).unwrap(); - app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); - } - app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("z").join("b.gpl"))); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// A hidden library keeps the id a reply showed for it when another library with the same file name is -/// loaded later. -#[test] -fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { - let (mut app, tx, base) = confined("hidden-stable-id"); - let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); - let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); - app.ui.library_panels = vec![panel]; - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); - std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); - let path = base.join("outside").join("z").join("b.gpl"); - std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); - app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); - app.ui.color_guide_limit = format!("loaded/{}", s(path)); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); - assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// An error from a panic hides a hidden library's path, also when the request stopped referring to -/// the library before it panicked. -#[test] -fn a_panic_reply_hides_a_library_hidden_before_the_request() { - let (mut app, tx, base) = confined("panic-hidden"); - let path = s(base.join("outside/b.svg")); - app.ui.color_guide_limit = format!("loaded/{path}"); - app.ui.status = format!("Loaded {path}"); - let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); - assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// An error shows a path outside the read root that the request sent by its file name, whether or not -/// a hidden library was loaded from that path, so that it reads the same either way. A path the -/// request sends does not show the app's own text that holds it, in an error or a result, and a result -/// shows that path by its file name as an error does. -#[test] -fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { - let (mut app, tx, base) = confined("client-echo"); - let path = s(base.join("outside/b.svg")); - // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. - let first = format!("loaded/{}", s(base.join("outside/a/b.svg"))); - let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); - app.ui.library_panels = vec![panel]; - app.ui.color_guide_limit = format!("loaded/{path}"); - app.ui.status = format!("Loaded {path}"); - let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; - for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { - let r = request(&mut app, &tx, "app.open", json!({"path": sent})); - assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); - } - let r = request(&mut app, &tx, "test.status", json!({"guess": path})); - assert_eq!(r["result"], "Loaded b.svg", "{r}"); - let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); - assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an -/// error the path outside the read root that the request sent. -#[test] -fn a_screenshot_reply_hides_paths_like_any_reply() { - let (mut app, _tx, base) = confined("deferred-reply"); - let path = s(base.join("outside/b.svg")); - app.ui.color_guide_limit = format!("loaded/{path}"); - let sent = s(base.join("outside/shot.png")); - let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); - let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); - let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); - vectorcraft_engine::file_access::confine(roots.as_ref(), || { - crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); - crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); - }); - assert_eq!(done["result"]["note"], "from b.svg", "{done}"); - assert_eq!(failed["error"], "could not write shot.png", "{failed}"); - let _ = std::fs::remove_dir_all(base); -} - -/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped -/// shows the file name. -#[test] -fn a_reply_hides_a_path_with_escaped_separators() { - let (mut app, tx, base) = confined("escaped-separators"); - let path = s(base.join("outside/b.svg")); - app.ui.color_guide_limit = format!("loaded/{path}"); - app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); - let r = request(&mut app, &tx, "test.status", json!({})); - assert_eq!(r["result"], "from file:b.svg", "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and -/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and -/// other text with a separator stay. -#[test] -fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { - let (mut app, tx, base) = confined("inspect-relative"); - let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ - "name": "set", - "actions": [{"name": "files", "steps": [ - ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], - ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], - ]}], - })) - .unwrap(); - app.ui.action_sets = vec![set]; - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; - let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); - assert_eq!(steps, &want, "{r}"); - let _ = std::fs::remove_dir_all(base); -} - /// Two libraries loaded from outside the read root under the same file name, only the second of them /// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and /// that id finds it. @@ -808,43 +473,6 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } -/// A library panel command given a hidden library's shown id opens the library and answers with -/// that id, not the library's path. -#[test] -fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() { - let (mut app, tx, base) = confined("panel-shown-id"); - let path = base.join("outside/x/Kit.gpl"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap(); - app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}})); - assert_eq!(r["ok"], true, "{r}"); - assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}"); - assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id"); - // The panel id it answers with names the panel in the panel commands. - let panel = r["result"]["panel"].clone(); - assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}"); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); - assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}"); - assert!(!r.to_string().contains("outside"), "{r}"); - let r = request( - &mut app, - &tx, - "engine.execute", - json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}), - ); - assert_eq!(r["ok"], true, "{r}"); - assert!(!r.to_string().contains("outside"), "{r}"); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}})); - assert_eq!(r["ok"], true, "{r}"); - assert!(!r.to_string().contains("outside"), "{r}"); - // Closed, the panel isn't found, and the error names it by the id given. - let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); - assert_eq!(r["ok"], false, "{r}"); - assert!(!r.to_string().contains("outside"), "{r}"); - let _ = std::fs::remove_dir_all(base); -} - /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -939,103 +567,6 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } -/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable -/// library's id that begins with it stays, and a script's report line and a command palette query -/// that end with it show the id shown. -#[test] -fn ui_inspect_rewrites_only_whole_hidden_library_ids() { - let (mut app, tx, base) = confined("inspect-whole-id"); - let hidden = format!("loaded/{}", s(base.join("i"))); - let readable = format!("loaded/{}", s(base.join("in/a.svg"))); - assert!(readable.starts_with(&hidden)); - let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; - app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; - app.ui.palette_query = format!("library:swatches:{hidden}"); - let text = format!("found {hidden}"); - let source = format!("vc.log({})", json!(text)); - app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); - for _ in 0..10_000 { - if app.session.script_pump().is_some() { - break; - } - } - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); - assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); - assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); - assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); - // A request that names the hidden library by the id shown keeps the readable id too. - let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); - let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); - assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id -/// reopens the closed panel of the library, and the reply does not show its path. -#[test] -fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { - let (mut app, tx, base) = confined("set-hidden-panel"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); - let real = format!("library:swatches:{theirs}"); - app.ui.docking_hidden.insert(real.clone(), hidden_place()); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let shown = "library:swatches:loaded/b.svg"; - assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); - let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); - assert_eq!(r["ok"], true, "{r}"); - assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); - let layout = crate::panel_docking::layout_of(&app.ui); - assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); - let hidden = json!(s(base.join("outside"))).to_string(); - assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); - // A docking command takes the shown id as its destination too. - let r = request( - &mut app, - &tx, - "engine.execute", - json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), - ); - assert_eq!(r["ok"], true, "{r}"); - assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); - // So does `ui.dock`'s move, whose destination is `target`. - let r = request( - &mut app, - &tx, - "engine.execute", - json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}), - ); - assert_eq!(r["ok"], true, "{r}"); - let layout = crate::panel_docking::layout_of(&app.ui); - assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); - assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); - // An error that quotes the panel shows the id the client gave, and other text is not rewritten. - let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); - assert_eq!(r["ok"], false, "{r}"); - assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); - let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); - assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); - let _ = std::fs::remove_dir_all(base); -} - -/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard -/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library. -#[test] -fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() { - let (mut app, tx, base) = confined("shortcut-hidden-panel"); - let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg"))); - crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7"); - app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }}))); - let r = request(&mut app, &tx, "ui.inspect", json!({})); - let shown = "panel:library:swatches:loaded/b.svg"; - let ui = &r["result"]["ui"]; - assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}"); - assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}"); - let hidden = json!(s(base.join("outside"))).to_string(); - assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); - let _ = std::fs::remove_dir_all(base); -} - /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..57185d9776 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,60 +141,24 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, - `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, - `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any - list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that - names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` - (either separator) that resolves outside the roots from the app's working folder; and the folder - preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` - params of a recorded action's steps in `ui.action_sets`, and the keys of shortcut overrides (`panel:<panel id>` - in `ui.shortcut_overrides` and an open Keyboard Shortcuts dialog's `ui.dialog.fields.overrides`), also when its - panel is closed. A path written with - either separator counts as a path, also one saved on another platform, and outside Windows a path that starts - with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its - file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`), and a reply matches it only as written, not with `/` - in place of the `\`. When another hidden library has the same - file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they - are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the - libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest - of the session in both replies and in the library commands below, also after another library with the same - file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a - library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next - free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id - shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `target`, `before`, `onto`, - `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` - reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path - appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its - path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal - fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins - another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the - read roots that the request sent by its file name, whether or not a library was loaded from it and whether the - request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, - `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are - matched before the paths the request sent, so a sent path written over part of a hidden library's path does not - keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` - keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, - `pngBase64` or `thumbnailBase64`, is sent as it is. + Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is + closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`, + `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session + first, then those only the UI state names), and each one keeps its id in both replies and in the library + commands below. The other entries keep their order, and the app's own state is not changed. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered `(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`, `symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the - others that take `library`), whose replies show it too. Over the control channel, `window.swatchLibrary` and - the other library panel commands answer with that id and its panel's id (`library:swatches:loaded/<file name>`), - and `ui.libraryPanel` and the `window.panel.*` commands take that panel id in `panel`, `anchor`, `before`, - `onto`, `above` and `below`. `swatch.library.copyToUser` refuses to copy a library file outside the read roots - without naming the file (the swatches of a document loaded from there are written from the loaded library, as - for a library loaded from data, and the document is not read again), and it and the library saves with - `user: true` leave out `path` when the user library folder is outside the read roots. + others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file + is outside the read roots without naming the file, and it and the library saves with `user: true` leave out + `path` when the user library folder is outside the read roots. - `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..24002f84bd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -763,7 +763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. From b2eca431aca7794cd71b4299d4ef55bbaa6d80a5 Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Thu, 12 Nov 2026 15:04:50 +0500 Subject: [PATCH 125/126] Blend: a closed spine pinned open still keeps two points apart (#6809) Pinning a closed replaced spine opens it with a copy of its first point at its end, so a two-point ring became three points and Delete Spine Point could leave a spine of two points in one place. The two-point check now counts the points left that are apart. --- crates/engine/src/cmd/live/blend.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 609936ecf7..d47e81befd 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -324,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> { if anchors.contains(&index) { return Err(bad(C, "a key object sits on that point")); } - if sp.anchors.len() <= 2 { + // A closed spine pinned open keeps a copy of its first point at its end (#6809), so the + // points left must be two apart, not only two in number. + let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p); + let first = rest.next(); + if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) { return Err(bad(C, "a spine keeps at least two points")); } sp.anchors.remove(index); From e399ac3290ade7fbd2642012f51a8719da219438 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Thu, 12 Nov 2026 18:25:12 +0700 Subject: [PATCH 126/126] Images: Remove Background in the task bar, Properties and the Control bar A selected image offers Remove Background in the Contextual Task Bar, Properties' Quick Actions and the Control bar, as the reference app does. A click runs it at once with its defaults, as one undo step; Object > Plug-ins > Image Tools keeps the dialog with Preview. --- crates/ui-egui/src/addons/image/tests.rs | 20 ++++++++++++++++++ crates/ui-egui/src/canvas/task_bar.rs | 6 ++++-- crates/ui-egui/src/canvas/tests.rs | 27 ++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 3 +++ crates/ui-egui/src/place.rs | 10 ++++++--- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 3 ++- 7 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/addons/image/tests.rs b/crates/ui-egui/src/addons/image/tests.rs index 349c35533e..64563e611b 100644 --- a/crates/ui-egui/src/addons/image/tests.rs +++ b/crates/ui-egui/src/addons/image/tests.rs @@ -56,6 +56,26 @@ fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) { crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), key.into(), v); } +/// Properties' Quick Actions and the Control bar offer Remove Background for a selected image, and +/// the Control bar's button removes it at once (no dialog) with the defaults, as one step. +#[test] +fn properties_and_the_control_bar_remove_the_background_at_once() { + use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame}; + let mut app = app(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let n = steps(&app); + assert!(has(&properties_frame(&mut app, 280.0), "Remove Background"), "Properties offers it"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let bar = control_frame(&mut app, &ctx, vec![]); + click_control(&mut app, &ctx, at(&bar, "Remove Background")); + assert!(app.ui.dialog.is_none(), "it runs at once"); + assert_eq!(steps(&app), n + 1, "one undo step"); + // The defaults (tolerance and softness 10): the white goes, the near-white ring fades. + let [corner, ring, block] = alphas(&app); + assert!(corner == 0 && ring > 0 && ring < 255 && block == 255, "{:?}", alphas(&app)); +} + /// Object › Plug-ins › Image Tools › Remove Background… opens the command dialog on Edge Color /// with Preview off. With Preview on the canvas shows the result for the values in the dialog, /// each change worked out again from the image as it was; Cancel puts the image back with no step diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index c54ab0da2a..4f50944cb3 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -96,11 +96,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else { // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); - // As in the reference app, an image offers Image Trace and Crop Image, and traced art offers - // Expand (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>). + // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and + // traced art offers Expand (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>, + // <https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>). if n == 1 && is_image { items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none())); items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none())); + items.push((tl!("Remove Background"), "eraser", crate::addons::image::REMOVE_BACKGROUND, none())); } // Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar // offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index e84d5cd076..503178a22b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -9353,6 +9353,33 @@ fn the_task_bar_traces_an_image_then_expands_the_trace() { assert_eq!(images, 0, "only the traced paths are kept"); } +/// For a selected image the Contextual Task Bar offers Remove Background, which clears the +/// background in the image's pixels at once (no dialog) with the command's defaults. +#[test] +fn the_task_bar_removes_a_selected_images_background() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let img = + image::RgbaImage::from_fn(20, 20, |x, y| image::Rgba(if (6..14).contains(&x) && (6..14).contains(&y) { [200, 0, 0, 255] } else { [255; 4] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "dot.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let labels = task_bar_labels(&mut app, &ctx); + let at = labels.iter().find(|(t, _)| t == "Remove Background").map(|(_, r)| r.center()).expect("Remove Background is offered"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + assert!(app.ui.dialog.is_none(), "it runs at once"); + let st = app.session.active().unwrap(); + let Some(NodeKind::Image(im)) = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind) else { panic!("an image") }; + let px = image::load_from_memory(&st.doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(10, 10).0[3]), (0, 255), "the white background is cleared, the red dot kept"); +} + /// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with /// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as /// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6dcd4a516f..07b53cc6d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -159,6 +159,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { crate::panels::image_trace::trace_button(app, ui, ui.available_width()); actions.push((tl!("Crop Image"), "ui.cropImage")); actions.push((tl!("Mask"), "object.maskImage")); + // Remove Background at once with its defaults, as the reference app's Quick Actions do + // (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>). + actions.push((tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND)); } // Object › Path's Offset Path and Simplify take paths: type alone could only fail, and offsets // live through the Appearance panel's Effect › Path › Offset Path (#2238). diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index ebb42852a9..de9407e2f3 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -577,8 +577,8 @@ pub fn image_summary(i: &Value) -> (String, String) { } /// The Control bar for the one selected image: Linked File or Embedded, its file name, colour mode -/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask and -/// Crop Image. Whether one image is selected. +/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask, +/// Crop Image and Remove Background. Whether one image is selected. pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let Some(i) = selected_image_info(app) else { return false }; let (name, details) = image_summary(i); @@ -595,7 +595,11 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool ui.separator(); link_buttons(app, ui, id, linked, &name, None); crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W); - for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "ui.cropImage", 84.0)] { + for (label, cmd, w) in [ + (tl!("Mask"), "object.maskImage", 52.0), + (tl!("Crop Image"), "ui.cropImage", 84.0), + (tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND, 128.0), + ] { if widgets::flat_button(ui, label, w).clicked() { crate::menus::invoke(app, cmd, json!({})); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 24002f84bd..9a33728d8b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1988,7 +1988,7 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Color Effects add-on: Find Nearest Swatch (2026-11-01) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1bc462b8f0..dbe69b7603 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -520,6 +520,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. |