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 01/72] 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 02/72] 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 03/72] 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 04/72] 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 05/72] 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 06/72] 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 d3a02f617dbb924725527ef15e076020d5f6e168 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 11:26:25 -0800 Subject: [PATCH 07/72] 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 08/72] 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 09/72] 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 b426822a9cf33461bc4fcbfac261e39efcea66b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:06:51 -0800 Subject: [PATCH 10/72] 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 11/72] 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 12/72] 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 13/72] 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 14/72] 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 15/72] 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 16/72] 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 e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 14:41:24 -0800 Subject: [PATCH 17/72] Type: Center on Top and Center on Bottom for type on a closed path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type › Type on a Path › Center on Top and Center on Bottom (type.pathCenter.top and type.pathCenter.bottom, also on the Contextual Task Bar for type on a closed path) slide the type along its path so that the middle of the span between its brackets lands on the path's topmost or bottommost point as the object is drawn, after its transform. On a level stretch, such as a rectangle's top edge, the middle of the stretch is used. The span keeps its length, and the first paragraph is set to Center unless it is Center or Justify Center. The type is not flipped. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them. When the type is already centered there, no undo step is recorded. This is VectorCraft's own behavior. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 2 + crates/engine/src/cmd/typemenu.rs | 148 +++++++++++++++ crates/engine/src/tests/pathtype.rs | 178 ++++++++++++++++++ crates/geom/src/arc.rs | 134 ++++++++++++- crates/ui-egui/src/canvas/task_bar.rs | 13 ++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/da.tsv | 2 + 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 | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 2 + 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/nl.tsv | 2 + 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 | 6 + 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 + crates/ui-egui/src/menus/tests.rs | 52 +++++ crates/ui-egui/src/menus/tree.rs | 8 +- crates/ui-egui/src/tests/taskbar.rs | 28 +++ docs/mcp.md | 20 ++ docs/target-app-parity.md | 1 + 37 files changed, 724 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 541389bb89..07ff0ded2e 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -638,6 +638,8 @@ pub(super) const ALLOWED: &[&str] = &[ "type.opticalMarginAlignment", "type.orientation.horizontal", "type.orientation.vertical", + "type.pathCenter.bottom", + "type.pathCenter.top", "type.pathOptions", "type.resetHorizontalScale", "type.resetTracking", diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index deaac8acc4..e9fd191676 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -60,6 +60,24 @@ pub fn specs() -> Vec<CommandSpec> { has_selection, path_options ), + cmd!( + "type.pathCenter.top", + "Center on Top", + ["Type", "Type on a Path"], + None, + "{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_top + ), + cmd!( + "type.pathCenter.bottom", + "Center on Bottom", + ["Type", "Type on a Path"], + None, + "{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_bottom + ), cmd!( "type.dynamicText", "Dynamic Text", @@ -410,6 +428,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { Ok(ids_json(&ids)) } +/// Whether type on a path `t` runs along one closed path (no open or compound path). +fn on_one_closed_path(t: &TextObject) -> bool { + matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed()) +} + +/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or +/// compound path, then those in a thread; None when they take every one. +fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> { + let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::<Vec<_>>(); + if !open.is_empty() { + let open = open.join(", "); + return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}")); + } + let frames = threaded_ids(d, ids); + (!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}")) +} + +/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of +/// it on one closed path and none of it in a thread. +fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let selected: Vec<NodeId> = st.selection.objects.to_vec(); + let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); + if paths.is_empty() { + return Err("select type on a closed path".into()); + } + path_center_refusal(d, &paths).map_or(Ok(()), Err) +} + +/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its +/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or +/// no height. +fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option<f64>)> { + let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; + let (from, to) = t.kind.path_span()?; + let arc = vectorcraft_geom::ArcPath::new(path); + if arc.is_empty() { + return None; + } + let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY }; + let middle = arc.topmost(flip * t.xf)? / arc.len(); + let run = to - from; + let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0); + // Without an end bracket the type runs once round; a full turn's end stays on its start. + let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) }); + Some((start, end)) +} + +/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within +/// rounding (0 and 1 are one place). +fn same_bracket(a: f64, b: f64) -> bool { + let d = (a - b).rem_euclid(1.0); + d.min(1.0 - d) <= 1e-9 +} + +fn path_center_top(s: &mut Session, p: &Value) -> Result<Value> { + path_center(s, p, false) +} + +fn path_center_bottom(s: &mut Session, p: &Value) -> Result<Value> { + path_center(s, p, true) +} + +/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed +/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as +/// drawn, and center the first paragraph, in one undo step; none when nothing changes. +fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result<Value> { + let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" }; + let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter); + // Each object the command changes, with its new brackets when they move and whether its first + // paragraph is set to Center. + let (ids, changes) = { + let d = &s.doc()?.doc; + let ids: Vec<NodeId> = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); + if ids.is_empty() { + return Err(bad(c, "select type on a closed path")); + } + if let Some(why) = path_center_refusal(d, &ids) { + return Err(bad(c, why)); + } + let (mut changes, mut flat) = (vec![], vec![]); + for id in &ids { + let Some(t) = path_text(d.node(*id)) else { continue }; + let TextKind::OnPath { start, end, .. } = &t.kind else { continue }; + let Some((to_start, to_end)) = centered_brackets(t, bottom) else { + flat.push(id.0.to_string()); + continue; + }; + let stays = same_bracket(*start, to_start) + && match (end, to_end) { + (None, None) => true, + (Some(a), Some(b)) => same_bracket(*a, b), + _ => false, + }; + let align = !centered(t.para_at(0).justify); + if !stays || align { + changes.push((*id, (!stays).then_some((to_start, to_end)), align)); + } + } + if !flat.is_empty() { + let flat = flat.join(", "); + return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}"))); + } + (ids, changes) + }; + if !changes.is_empty() { + s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| { + for (id, brackets, align) in &changes { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) { + (*start, *end) = *b; + } + if *align { + t.edit_paras(Some(0..1), |para| para.justify = Justify::Center); + } + refresh_bounds(t); + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind { + TextKind::OnPath { start, end, .. } => (*start, *end), + _ => (0.0, None), + }); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "start": start, "end": end})) +} + /// The options `type.pathOptions` sets; with none of them given it queries. const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"]; diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs index 5ead0cbd22..8aa8c05684 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() { assert!(origins(&s, id)[0].distance(first) < 8.0); } +/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with +/// type on it from its start. +fn on_circle(s: &mut Session, text: &str) -> NodeId { + path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text) +} + +#[test] +fn center_on_top_and_bottom_of_a_circle() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id)); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + // Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered. + let (start, end) = brackets(&s, id); + assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}"); + assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); + let top = ink(&s, id); + assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string())); + // Again: nothing moves, and no undo step is recorded. + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(undo_len(&s), steps + 1); + // The bottom: 6 o'clock, outside the circle (the type is not flipped). + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}"); + let bottom = ink(&s, id); + assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string())); + // Justify Center stays as it is. + s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(brackets(&s, id).0, 0.25)); + // Brackets already in place with another alignment: only the alignment changes, in one step. + s.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + let (steps, before) = (undo_len(&s), brackets(&s, id)); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); +} + +#[test] +fn center_on_top_twice_records_one_step_for_any_span() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] { + s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + s.execute(c, &json!({})).unwrap(); + let (steps, placed) = (undo_len(&s), brackets(&s, id)); + // Again: the brackets stay exactly where they are, and no undo step is recorded. + s.execute(c, &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}"); + } + } +} + +#[test] +fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + // A span of a fifth of the path keeps its length. + s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}"); + // Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, _) = brackets(&s, id); + assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}"); + let bounds = text(&s, id).bounds().unwrap(); + let top = ink(&s, id); + assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}"); + // A rectangle: the middle of its top edge. + let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}"); + assert!((ink(&s, id).center().x - 200.0).abs() < 0.5); +} + +#[test] +fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() { + let mut s = session(); + let a = on_circle(&mut s, "OOOO"); + let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap(); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0])); + assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625)); + assert_eq!(undo_len(&s), steps + 1); + // Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of + // the circle, at its bottom. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a)); + assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale); + let bottom = ink(&s, a); + assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}"); + // Vertical type on a path moves the same way. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert!(near(brackets(&s, a).0, 0.75)); +} + +#[test] +fn center_on_top_refuses_open_compound_and_threaded_type() { + let mut s = session(); + let circle = on_circle(&mut s, "OOOO"); + let line = on_line(&mut s); + s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap(); + let steps = undo_len(&s); + let before = brackets(&s, circle); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}"); + // Nothing changes, the type on the circle included, and the same holds for an explicit list. + assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before)); + let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}"); + s.execute("select.set", &json!({"ids": [circle.0]})).unwrap(); + for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] { + assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}"); + } + assert_eq!(undo_len(&s), steps); + // Two circles side by side in one path: a compound path. + let pair = path_type( + &mut s, + "M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z", + [90.0, 450.0], + "OOOO", + ); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}"); + // A closed path that is level all round has no height. + let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO"); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}"); + // A frame of a thread is refused, alone or with other type on a closed path. + let threaded = on_circle(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap(); + let before = brackets(&s, threaded); + for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] { + let steps = undo_len(&s); + let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string(); + assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}"); + assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before)); + } + // Without type on one closed path selected, the commands are not available. + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + for (ids, why) in [ + (json!([p]), "select type on a closed path"), + (json!([line.0]), "open or compound path"), + (json!([circle.0, line.0]), "open or compound path"), + (json!([threaded.0]), "in a thread"), + ] { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + let e = s.execute(c, &json!({})).unwrap_err().to_string(); + assert!(e.contains(why), "{c} {ids}: {e}"); + } + } +} + #[test] fn align_to_path_and_spacing() { let mut s = session(); diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs index c4fe640ae2..644dc44f6a 100644 --- a/crates/geom/src/arc.rs +++ b/crates/geom/src/arc.rs @@ -2,7 +2,7 @@ //! distance along it of the point nearest another (type on a path lays its glyphs out and the //! selection tools drag its brackets by distance along the path). -use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2}; +use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest, PathSeg, Point, Vec2}; use crate::PathData; @@ -76,6 +76,78 @@ impl ArcPath { pub fn fraction_at(&self, p: Point) -> Option<f64> { (self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0)) } + + /// The distance along the path of the middle of its topmost points (the least y) where `xf` + /// draws it: the topmost point when there is one, the middle of a level stretch (a + /// rectangle's top edge), and when several points or stretches are topmost, the middle of the + /// shortest part of the path that holds them all. None for a path with no length, or a closed + /// path whose every point is topmost. + pub fn topmost(&self, xf: Affine) -> Option<f64> { + let drawn: Vec<(PathSeg, PathSeg, f64, f64)> = self.segs.iter().map(|&(s, c, l)| (s, xf * s, c, l)).collect(); + // The candidates for the least y in each segment: its ends and where it turns. + let turns = |d: &PathSeg| [0.0, 1.0].into_iter().chain(d.extrema()).collect::<Vec<f64>>(); + let mut bounds: Option<kurbo::Rect> = None; + let mut top = f64::INFINITY; + for (_, d, _, _) in &drawn { + let b = d.bounding_box(); + bounds = Some(bounds.map_or(b, |a| a.union(b))); + top = turns(d).into_iter().map(|t| d.eval(t).y).fold(top, f64::min); + } + let b = bounds?; + if !(top.is_finite() && b.width().is_finite() && b.height().is_finite()) { + return None; + } + // Points this close to the top count as topmost: a part of the drawn size, and no less than + // the rounding error of coordinates this far from the origin. + let tol = (1e-9 * (b.width() + b.height())).max(64.0 * f64::EPSILON * b.y0.abs().max(b.y1.abs())); + // Stretches this close along the path meet. + let near = 1e-9 * self.len; + // The topmost points as stretches of distance along the path, in order. + let mut runs: Vec<(f64, f64)> = vec![]; + for (s, d, c, l) in &drawn { + let db = d.bounding_box(); + if db.y1 - db.y0 <= tol && db.y0 <= top + tol { + runs.push((*c, c + l)); + continue; + } + for t in turns(d) { + if d.eval(t).y <= top + tol { + let at = c + s.subsegment(0.0..t).arclen(ACCURACY).min(*l); + runs.push((at, at)); + } + } + } + runs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Stretches that meet or overlap are one. + let mut merged: Vec<(f64, f64)> = vec![]; + for (a, z) in runs { + match merged.last_mut() { + Some(last) if a <= last.1 + near => last.1 = last.1.max(z), + _ => merged.push((a, z)), + } + } + let (first, last) = (*merged.first()?, *merged.last()?); + if !self.closed { + return Some((first.0 + last.1) * 0.5); + } + let len = self.len; + // Round a closed path, the shortest part holding every stretch leaves out the longest + // gap between two of them (the gap from the last round to the first included). + let wrap = first.0 + len - last.1; + let (mut gap, mut from, mut to) = (wrap, first.0, last.1); + for w in merged.windows(2) { + if let [a, b] = w + && b.0 - a.1 > gap + { + (gap, from, to) = (b.0 - a.1, b.0, a.1); + } + } + if gap <= near { + return None; + } + let run = (to - from).rem_euclid(len); + Some((from + run * 0.5).rem_euclid(len)) + } } #[cfg(test)] @@ -99,4 +171,64 @@ mod tests { assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6); assert!(ArcPath::new(&PathData::default()).is_empty()); } + + fn polyline(points: &[Point], closed: bool) -> PathData { + PathData::single(crate::SubPath::polyline(points, closed)) + } + + /// Where `topmost` puts the middle, drawn by `xf`. + fn top_point(a: &ArcPath, xf: Affine) -> Point { + xf * a.at(a.topmost(xf).unwrap()).0 + } + + #[test] + fn topmost_points_as_drawn() { + // A circle starts at 9 o'clock and runs clockwise (y down): its top is a quarter of the way round, its + // bottom three quarters. + let circle = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!((circle.topmost(Affine::IDENTITY).unwrap() - circle.len() * 0.25).abs() < 1e-6); + assert!((circle.topmost(Affine::FLIP_Y).unwrap() - circle.len() * 0.75).abs() < 1e-6); + // As the transform draws it: turned a quarter turn the other way, 3 o'clock is at the top. + let turned = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(50.0, 50.0)); + assert!((circle.topmost(turned).unwrap() - circle.len() * 0.5).abs() < 1e-6); + // Turned part way, the top lies inside a segment. + let tilted = Affine::rotate_about(0.4, Point::new(50.0, 50.0)); + assert!(top_point(&circle, tilted).distance(Point::new(50.0, 0.0)) < 0.05, "{:?}", top_point(&circle, tilted)); + // An ellipse squashed and skewed by its transform. + let skewed = Affine::skew(0.5, 0.0) * Affine::scale_non_uniform(2.0, 0.5); + let p = top_point(&circle, skewed); + let lowest = (0..3600).map(|i| (skewed * circle.at(circle.len() * i as f64 / 3600.0).0).y).fold(f64::INFINITY, f64::min); + assert!((p.y - lowest).abs() < 1e-3, "{p:?} {lowest}"); + // A level top: the middle of the top edge, for a rectangle and a rounded rectangle. + for path in [shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), shapes::rounded_rectangle(Rect::new(10.0, 20.0, 110.0, 70.0), 8.0)] { + let a = ArcPath::new(&path); + assert!(top_point(&a, Affine::IDENTITY).distance(Point::new(60.0, 20.0)) < 1e-6, "{:?}", top_point(&a, Affine::IDENTITY)); + assert!(top_point(&a, Affine::FLIP_Y).distance(Point::new(60.0, -70.0)) < 1e-6); + } + // Far from the origin the top is still the top edge's middle. + let square = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + let far = Affine::translate((1e12, 1e12)); + assert!(top_point(&square, far).distance(Point::new(1e12 + 50.0, 1e12)) < 1e-3, "{:?}", top_point(&square, far)); + // A level top across the path's start: the start in the middle of the top edge. + let across = ArcPath::new(&polyline( + &[Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(0.0, 50.0), Point::new(0.0, 0.0)], + true, + )); + assert!(top_point(&across, Affine::IDENTITY).distance(Point::new(50.0, 0.0)) < 1e-6); + // Two peaks: the middle of the shortest part that holds both, the dip between them. + let peaks = ArcPath::new(&polyline( + &[Point::new(0.0, 10.0), Point::new(2.0, 0.0), Point::new(4.0, 5.0), Point::new(6.0, 0.0), Point::new(8.0, 10.0)], + true, + )); + assert!(top_point(&peaks, Affine::IDENTITY).distance(Point::new(4.0, 5.0)) < 1e-6, "{:?}", top_point(&peaks, Affine::IDENTITY)); + // An open path: the middle of its topmost points too. + let open = ArcPath::new(&polyline(&[Point::new(0.0, 10.0), Point::new(5.0, 0.0), Point::new(10.0, 10.0)], false)); + assert!(top_point(&open, Affine::IDENTITY).distance(Point::new(5.0, 0.0)) < 1e-6); + // No top: a closed path that is level all round, and a path with no length. + let flat = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 10.0, 100.0, 10.0))); + assert!(!flat.is_empty() && flat.topmost(Affine::IDENTITY).is_none()); + assert!(ArcPath::new(&PathData::default()).topmost(Affine::IDENTITY).is_none()); + let point = ArcPath::new(&polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], true)); + assert!(point.topmost(Affine::IDENTITY).is_none()); + } } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f0790..3fe0f99a92 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -158,6 +158,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } + center_on_path_items(app, &mut items); } } } else if is_image { @@ -349,6 +350,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a +/// closed path. +fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { + for (label, icon, id) in + [(tl!("Center on Top"), "dc-al-top", "type.pathCenter.top"), (tl!("Center on Bottom"), "dc-al-bottom", "type.pathCenter.bottom")] + { + if crate::menus::enabled(app, id) { + items.push((label, icon, id, json!({}))); + } + } +} + /// The Contextual Task Bar's Image Trace button: a trace with the Default preset. const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace"; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..d1658c0e24 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3584,6 +3584,8 @@ digits Malayalam Малаялам Type on a Path Текст по пътя Type on a Path Options Опции за текст по пътя Type on a Path Options… Опции за текст по път… + Center on Top Центриране горе + Center on Bottom Центриране долу Type on a Path Tool Инструмент „Текст по път“ Type Orientation Ориентация на текста Type stays text, and every stroke is a line with its lineweight and dashes. Текстът остава текст, а всеки щрих е линия с дебелина и чертички. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..f8ed0e1588 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -384,6 +384,8 @@ Stair Step Stepenice Gravity Gravitacija Type on a Path Options… Opcije teksta na putanji… + Center on Top Centriraj na vrhu + Center on Bottom Centriraj na dnu Threaded Text Povezani tekst Create Kreiraj Release Selection Oslobodi odabir diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..a8c2f0e2a5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2886,6 +2886,10 @@ digits Malayalam Malaiàlam Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic @msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el text sobre un traçat obert o compost no es pot centrar a dalt o a baix: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el text sobre un traçat sense longitud o sense alçada no es pot centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el text sobre un traçat d’un text enllaçat no es pot centrar a dalt o a baix: {frames} +@msg select type on a closed path seleccioneu text sobre un traçat tancat Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat Options Opcions @@ -4626,6 +4630,8 @@ digits Malayalam Malaiàlam Type on a Path Text sobre un traçat Type on a Path Options Opcions de text sobre un traçat Type on a Path Options… Opcions de text sobre un traçat… + Center on Top Centra a dalt + Center on Bottom Centra a baix Type on a Path Tool Eina de text sobre un traçat Type Optimized Optimitzat per al text Type Orientation Orientació del text diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..af7e79a8ea 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -391,6 +391,8 @@ Stair Step Schody Gravity Gravitace Type on a Path Options… Možnosti textu na cestě… + Center on Top Vystředit nahoře + Center on Bottom Vystředit dole Threaded Text Zřetězený text Create Vytvořit Release Selection Uvolnit výběr @@ -408,6 +410,10 @@ Optical Margin Alignment Optické zarovnání okrajů Dynamic Text Dynamický text @msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text na otevřené nebo složené cestě nelze vystředit nahoře ani dole: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text na cestě bez délky nebo bez výšky nelze vystředit: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text na cestě ve zřetězeném textu nelze vystředit nahoře ani dole: {frames} +@msg select type on a closed path vyberte text na uzavřené cestě Fill with Placeholder Text Vyplnit zástupným textem Insert Inline Symbol Vložit symbol do textu Show Hidden Characters Zobrazit skryté znaky diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..9c0e6205f9 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -352,6 +352,8 @@ Stair Step Trappetrin Gravity Tyngdekraft Type on a Path Options… Indstillinger for tekst på kurve… + Center on Top Centrer øverst + Center on Bottom Centrer nederst Threaded Text Sammenkædet tekst Create Opret Release Selection Frigør markering diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..0654e68296 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2291,6 +2291,10 @@ file-filter Templates Vorlagen Optical Margin Alignment Optischer Randausgleich Dynamic Text Dynamischer Text @msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} Pfadtext auf einem offenen oder zusammengesetzten Pfad lässt sich nicht oben oder unten zentrieren: {open} +@msg type on a path with no length or no height cannot be centered: {flat} Pfadtext auf einem Pfad ohne Länge oder ohne Höhe lässt sich nicht zentrieren: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} Pfadtext in verkettetem Text lässt sich nicht oben oder unten zentrieren: {frames} +@msg select type on a closed path wählen Sie Pfadtext auf einem geschlossenen Pfad aus Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert Options Optionen @@ -3618,6 +3622,8 @@ effect Points Punkte Type on a Path Pfadtext Type on a Path Options Pfadtextoptionen Type on a Path Options… Pfadtextoptionen… + Center on Top Oben zentrieren + Center on Bottom Unten zentrieren Type on a Path Tool Pfadtext-Werkzeug Type Orientation Textrichtung Type stays text, and every stroke is a line with its lineweight and dashes. Text bleibt Text, und jede Kontur ist eine Linie mit ihrer Linienstärke und ihren Strichen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..7993587dac 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3053,6 +3053,10 @@ theme Appearance Εμφάνιση Optical Margin Alignment Οπτική στοίχιση περιθωρίου Dynamic Text Δυναμικό κείμενο @msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} κείμενο σε ανοιχτή ή σύνθετη διαδρομή δεν μπορεί να κεντραριστεί επάνω ή κάτω: {open} +@msg type on a path with no length or no height cannot be centered: {flat} κείμενο σε διαδρομή χωρίς μήκος ή χωρίς ύψος δεν μπορεί να κεντραριστεί: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να κεντραριστεί επάνω ή κάτω: {frames} +@msg select type on a closed path επιλέξτε κείμενο σε κλειστή διαδρομή Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη @@ -4005,6 +4009,8 @@ digits Malayalam Μαλαγιάλαμ Type on a Path Κείμενο σε διαδρομή Type on a Path Options Επιλογές κειμένου σε διαδρομή Type on a Path Options… Επιλογές κειμένου σε διαδρομή… + Center on Top Κεντράρισμα επάνω + Center on Bottom Κεντράρισμα κάτω Type on a Path Tool Κείμενο σε διαδρομή (Type on a Path Tool) Type Orientation Προσανατολισμός κειμένου Type stays text, and every stroke is a line with its lineweight and dashes. Το κείμενο διατηρείται ως κείμενο και κάθε περίγραμμα γίνεται γραμμή με το πάχος και τις παύλες του. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..84f6114c87 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2337,6 +2337,10 @@ font-technology Variable Variables Optical Margin Alignment Alineación óptica de márgenes Dynamic Text Texto dinámico @msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el texto en un trazado abierto o compuesto no se puede centrar arriba o abajo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el texto en un trazado sin longitud o sin altura no se puede centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el texto en trazado de un texto enlazado no se puede centrar arriba o abajo: {frames} +@msg select type on a closed path seleccione texto en un trazado cerrado Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado Options Opciones @@ -3672,6 +3676,8 @@ digits Malayalam Malayálam Type on a Path Texto en trazado Type on a Path Options Opciones de texto en trazado Type on a Path Options… Opciones de texto en trazado… + Center on Top Centrar arriba + Center on Bottom Centrar abajo Type on a Path Tool Herramienta Texto en trazado Type Orientation Orientación del texto Type stays text, and every stroke is a line with its lineweight and dashes. El texto sigue siendo texto, y cada trazo es una línea con su grosor y guiones. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..3c688e2b93 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -388,6 +388,8 @@ Stair Step Porras Gravity Painovoima Type on a Path Options… Teksti polulla -asetukset… + Center on Top Keskitä ylös + Center on Bottom Keskitä alas Threaded Text Ketjutettu teksti Create Luo Release Selection Vapauta valinta @@ -405,6 +407,10 @@ Optical Margin Alignment Optinen reunustasaus Dynamic Text Dynaaminen teksti @msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} avoimella polulla tai yhdistelmäpolulla olevaa tekstiä ei voi keskittää ylös tai alas: {open} +@msg type on a path with no length or no height cannot be centered: {flat} polkutekstiä ei voi keskittää polulle, jolla ei ole pituutta tai korkeutta: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} ketjutetun tekstin polkutekstiä ei voi keskittää ylös tai alas: {frames} +@msg select type on a closed path valitse suljetulla polulla oleva teksti Fill with Placeholder Text Täytä paikkamerkkitekstillä Insert Inline Symbol Lisää tekstinsisäinen symboli Show Hidden Characters Näytä piilotetut merkit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..9c48aaeb56 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2329,6 +2329,10 @@ font-technology Variable Variables Optical Margin Alignment Alignement optique des marges Dynamic Text Texte dynamique @msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} un texte curviligne sur un tracé ouvert ou un tracé transparent ne peut pas être centré en haut ou en bas : {open} +@msg type on a path with no length or no height cannot be centered: {flat} un texte curviligne sur un tracé sans longueur ou sans hauteur ne peut pas être centré : {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} le texte curviligne d’un texte lié ne peut pas être centré en haut ou en bas : {frames} +@msg select type on a closed path sélectionnez un texte curviligne sur un tracé fermé Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé Options Options @@ -3663,6 +3667,8 @@ digits Malayalam Malayalam Type on a Path Texte curviligne Type on a Path Options Options de texte curviligne Type on a Path Options… Options de texte curviligne… + Center on Top Centrer en haut + Center on Bottom Centrer en bas Type on a Path Tool Outil Texte curviligne Type Orientation Orientation du texte Type stays text, and every stroke is a line with its lineweight and dashes. Le texte reste du texte, et chaque contour devient une ligne avec son épaisseur et ses tirets. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..8f5e9e7121 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3139,6 +3139,10 @@ antiAlias Sharp शार्प Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट @msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} खुले पाथ या कंपाउंड पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {open} +@msg type on a path with no length or no height cannot be centered: {flat} बिना लंबाई या बिना ऊँचाई वाले पाथ पर टाइप केंद्रित नहीं किया जा सकता: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {frames} +@msg select type on a closed path बंद पाथ पर टाइप चुनें Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़ Optimized ऑप्टिमाइज़्ड Options ऑप्शन्स @@ -5014,6 +5018,8 @@ antiAlias Sharp शार्प Type on a Path पाथ पर टाइप Type on a Path Options पाथ पर टाइप ऑप्शन्स Type on a Path Options… पाथ पर टाइप ऑप्शन्स… + Center on Top ऊपर केंद्रित करें + Center on Bottom नीचे केंद्रित करें Type on a Path Tool पाथ पर टाइप टूल Type Optimized टाइप ऑप्टिमाइज़ Type Orientation प्रकार अभिविन्यास diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..42b92d9e7d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2824,6 +2824,10 @@ file-filter Templates Sablonok Optical Margin Alignment Optikai margóigazítás Dynamic Text Dinamikus szöveg @msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nyitott vagy összetett útvonalon lévő útvonalszöveg nem igazítható középre felül vagy alul: {open} +@msg type on a path with no length or no height cannot be centered: {flat} a hossz vagy magasság nélküli útvonalon lévő útvonalszöveg nem igazítható középre: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} a láncolt szövegben lévő útvonalszöveg nem igazítható középre felül vagy alul: {frames} +@msg select type on a closed path jelöljön ki zárt útvonalon lévő útvonalszöveget Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált Options Beállítások @@ -4382,6 +4386,8 @@ mark-style Japanese Japán Type on a Path Útvonalszöveg Type on a Path Options Útvonalszöveg beállításai Type on a Path Options… Útvonalszöveg beállításai… + Center on Top Középre igazítás felül + Center on Bottom Középre igazítás alul Type on a Path Tool Útvonalszöveg Type Optimized Szövegre optimalizált Type Orientation Szöveg iránya diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..f717f4b892 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -352,6 +352,8 @@ Stair Step Undakan tangga Gravity Gravitasi Type on a Path Options… Opsi teks pada jalur… + Center on Top Pusatkan di atas + Center on Bottom Pusatkan di bawah Threaded Text Teks beruntun Create Buat Release Selection Lepaskan seleksi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..f47795e013 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2367,6 +2367,10 @@ file-filter Templates Modelli Optical Margin Alignment Allineamento ottico dei margini Dynamic Text Testo dinamico @msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} il testo su un tracciato aperto o composto non può essere centrato in alto o in basso: {open} +@msg type on a path with no length or no height cannot be centered: {flat} il testo su un tracciato senza lunghezza o senza altezza non può essere centrato: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} il testo su tracciato in un testo concatenato non può essere centrato in alto o in basso: {frames} +@msg select type on a closed path seleziona del testo su un tracciato chiuso Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato Options Opzioni @@ -3710,6 +3714,8 @@ digits Malayalam Malayalam Type on a Path Testo su tracciato Type on a Path Options Opzioni testo su tracciato Type on a Path Options… Opzioni testo su tracciato… + Center on Top Centra in alto + Center on Bottom Centra in basso Type on a Path Tool Strumento Testo su tracciato Type Orientation Orientamento testo Type stays text, and every stroke is a line with its lineweight and dashes. Il testo resta testo e ogni traccia diventa una linea con il suo spessore e i suoi tratteggi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..3910041eaf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -409,6 +409,8 @@ digits Malayalam マラヤーラム数字 Stair Step 階段 Gravity 引力 Type on a Path Options… パス上文字オプション… + Center on Top 上端に中央揃え + Center on Bottom 下端に中央揃え Threaded Text スレッドテキストオプション Create 作成 Release Selection 選択部分をリリース @@ -436,6 +438,10 @@ digits Malayalam マラヤーラム数字 Optical Margin Alignment 最適なマージン揃え Dynamic Text ダイナミックテキスト @msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} オープンパスや複合パス上のパス上文字は上端または下端に中央揃えできません: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 長さや高さのないパスのパス上文字は中央揃えできません: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} スレッドテキスト内のパス上文字は上端または下端に中央揃えできません: {frames} +@msg select type on a closed path クローズパス上のパス上文字を選択してください Fill with Placeholder Text サンプルテキストの割り付け Insert Inline Symbol インラインシンボルを挿入 Show Hidden Characters 制御文字を表示 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..570ca353a1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -317,6 +317,8 @@ Stair Step საფეხურები Gravity გრავიტაცია Type on a Path Options… ტრაექტორიაზე ტექსტის პარამეტრები… + Center on Top ზემოთ ცენტრში მოთავსება + Center on Bottom ქვემოთ ცენტრში მოთავსება Threaded Text დაკავშირებული ტექსტი Create შექმნა Release Selection მონიშნულის მოხსნა @@ -334,6 +336,10 @@ Optical Margin Alignment ოპტიკური არშიის სწორება Dynamic Text დინამიკური ტექსტი @msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} ღია ან შედგენილ ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {open} +@msg type on a path with no length or no height cannot be centered: {flat} ტექსტი ვერ მოთავსდება ცენტრში ტრაექტორიაზე, რომელსაც არ აქვს სიგრძე ან სიმაღლე: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {frames} +@msg select type on a closed path მონიშნეთ დახურულ ტრაექტორიაზე ტექსტი Fill with Placeholder Text შემავსებელი ტექსტით შევსება Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა Show Hidden Characters დამალული სიმბოლოების ჩვენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..3d42eb69d8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -402,6 +402,8 @@ digits Malayalam 말라얄람어 Stair Step 계단 Gravity 중력 Type on a Path Options… 패스 위 문자 옵션… + Center on Top 위쪽에 가운데 맞춤 + Center on Bottom 아래쪽에 가운데 맞춤 Threaded Text 연결된 문자 Create 만들기 Release Selection 선택 해제 @@ -429,6 +431,10 @@ digits Malayalam 말라얄람어 Optical Margin Alignment 시각적 여백 정렬 Dynamic Text 동적 텍스트 @msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 열린 패스나 컴파운드 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 길이나 높이가 없는 패스 위 문자는 가운데 맞춤할 수 없습니다: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 연결된 문자의 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {frames} +@msg select type on a closed path 닫힌 패스 위 문자를 선택하세요 Fill with Placeholder Text 자리 표시 문자로 채우기 Insert Inline Symbol 인라인 심볼 삽입 Show Hidden Characters 숨겨진 문자 표시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..3ef28b6895 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -366,6 +366,8 @@ Stair Step Trap Gravity Zwaartekracht Type on a Path Options… Opties voor tekst op pad… + Center on Top Boven centreren + Center on Bottom Onder centreren Threaded Text Gekoppelde tekst Create Maken Release Selection Selectie vrijgeven diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..600552b22b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -350,6 +350,8 @@ Stair Step Schodki Gravity Grawitacja Type on a Path Options… Opcje tekstu na ścieżce… + Center on Top Wyśrodkuj na górze + Center on Bottom Wyśrodkuj na dole Threaded Text Tekst wątkowy Create Utwórz Release Selection Uwolnij zaznaczenie @@ -367,6 +369,10 @@ Optical Margin Alignment Optyczne wyrównanie marginesów Dynamic Text Tekst dynamiczny @msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nie można wyśrodkować na górze ani na dole tekstu na ścieżce otwartej lub złożonej: {open} +@msg type on a path with no length or no height cannot be centered: {flat} nie można wyśrodkować tekstu na ścieżce bez długości lub bez wysokości: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} nie można wyśrodkować na górze ani na dole tekstu na ścieżce w tekście wątkowym: {frames} +@msg select type on a closed path zaznacz tekst na zamkniętej ścieżce Fill with Placeholder Text Wypełnij tekstem zastępczym Insert Inline Symbol Wstaw symbol w tekście Show Hidden Characters Pokaż ukryte znaki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..13e9424fdd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1933,6 +1933,10 @@ antiAlias Sharp Nítido Optical Margin Alignment Alinhamento de Margem Óptica Dynamic Text Texto Dinâmico @msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} o texto em um caminho aberto ou composto não pode ser centralizado em cima ou embaixo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} o texto em um caminho sem comprimento ou sem altura não pode ser centralizado: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} o texto em um caminho de um texto encadeado não pode ser centralizado em cima ou embaixo: {frames} +@msg select type on a closed path selecione texto em um caminho fechado Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado Options Opções @@ -3021,6 +3025,8 @@ digits Malayalam Malaiala Type becomes outlines, and strokes a CAD line can't draw become filled shapes. O texto vira contornos, e os traços que uma linha CAD não pode desenhar viram formas preenchidas. Type on a Path Texto em um Caminho Type on a Path Options… Opções de Texto em um Caminho… + Center on Top Centralizar em Cima + Center on Bottom Centralizar em Baixo Type on a Path Tool Ferramenta Texto em um Caminho Type stays text, and every stroke is a line with its lineweight and dashes. O texto permanece texto, e todo traço é uma linha com sua espessura e tracejados. Type: Tipo: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..e66f350c6a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2362,6 +2362,10 @@ file-filter Templates Шаблоны Optical Margin Alignment Оптическое выравнивание полей Dynamic Text Динамический текст @msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} нельзя центрировать вверху или внизу текст по незамкнутому или составному контуру: {open} +@msg type on a path with no length or no height cannot be centered: {flat} нельзя центрировать текст по контуру без длины или без высоты: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} нельзя центрировать вверху или внизу текст по контуру в связанном тексте: {frames} +@msg select type on a closed path выделите текст по замкнутому контуру Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано Options Параметры @@ -3704,6 +3708,8 @@ digits Malayalam Малаялам Type on a Path Текст по контуру Type on a Path Options Параметры текста по контуру Type on a Path Options… Параметры текста по контуру… + Center on Top Центрировать вверху + Center on Bottom Центрировать внизу Type on a Path Tool Инструмент «Текст по контуру» Type Orientation Ориентация текста Type stays text, and every stroke is a line with its lineweight and dashes. Текст остаётся текстом, и каждая обводка — линия с её толщиной и штрихами. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..cfdd5bef9d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2276,6 +2276,10 @@ font-technology Variable Variabla Optical Margin Alignment Optisk marginaljustering Dynamic Text Dynamisk text @msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text på en öppen eller sammansatt bana kan inte centreras överst eller nederst: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text på en bana utan längd eller utan höjd kan inte centreras: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text på bana i länkad text kan inte centreras överst eller nederst: {frames} +@msg select type on a closed path markera text på en sluten bana Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad Options Alternativ @@ -3593,6 +3597,8 @@ digits Malayalam Malayalam Type on a Path Text på bana Type on a Path Options Alternativ för text på bana Type on a Path Options… Alternativ för text på bana… + Center on Top Centrera överst + Center on Bottom Centrera nederst Type on a Path Tool Verktyg för text på bana Type Orientation Textorientering Type stays text, and every stroke is a line with its lineweight and dashes. Text förblir text, och varje kontur blir en linje med sin linjetjocklek och sina streck. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..606e994763 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2148,6 +2148,10 @@ shape Open Açık Optical Margin Alignment Optik Kenar Boşluğu Hizalaması Dynamic Text Dinamik Metin @msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} açık veya bileşik bir yol üzerindeki yazı üstte veya altta ortalanamaz: {open} +@msg type on a path with no length or no height cannot be centered: {flat} uzunluğu ya da yüksekliği olmayan yol üzerindeki yazı ortalanamaz: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} zincirli metindeki yol üzerindeki yazı üstte veya altta ortalanamaz: {frames} +@msg select type on a closed path kapalı bir yol üzerindeki yazıyı seçin Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş Options Seçenekler @@ -3481,6 +3485,8 @@ digits Malayalam Malayalam Type on a Path Bir Yola Yazın Type on a Path Options Yol Seçenekleri Üzerine Yazma Type on a Path Options… Yol Seçenekleri Üzerine Yazma… + Center on Top Üstte ortala + Center on Bottom Altta ortala Type on a Path Tool Yola Yazma Aracı Type Orientation Tip Yönü Type stays text, and every stroke is a line with its lineweight and dashes. Yazı metin olarak kalır ve her kontur, çizgi kalınlığı ve tireleriyle birlikte bir çizgidir. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..fa5cd060d6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2359,6 +2359,10 @@ file-filter Templates Шаблони Optical Margin Alignment Оптичне вирівнювання полів Dynamic Text Динамічний текст @msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} не можна центрувати вгорі чи внизу текст за незамкненим або складеним контуром: {open} +@msg type on a path with no length or no height cannot be centered: {flat} не можна центрувати текст за контуром без довжини або без висоти: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} не можна центрувати вгорі чи внизу текст за контуром у пов’язаному тексті: {frames} +@msg select type on a closed path виділіть текст за замкненим контуром Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано Options Параметри @@ -3703,6 +3707,8 @@ effect Points Точки Type on a Path Текст за контуром Type on a Path Options Параметри тексту за контуром Type on a Path Options… Параметри тексту за контуром… + Center on Top Центрувати вгорі + Center on Bottom Центрувати внизу Type on a Path Tool Інструмент «Текст за контуром» Type Orientation Орієнтація тексту Type stays text, and every stroke is a line with its lineweight and dashes. Текст залишається текстом, і кожне обведення — лінія з її товщиною та штрихами. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..22ccb6572a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2637,6 +2637,10 @@ digits Hindi Hindi Optical Margin Alignment Căn lề quang học Dynamic Text Văn bản động @msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn mở hoặc đường dẫn phức: {open} +@msg type on a path with no length or no height cannot be centered: {flat} không thể căn giữa chữ trên đường dẫn không có độ dài hoặc không có chiều cao: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn trong văn bản chuỗi: {frames} +@msg select type on a closed path hãy chọn chữ trên một đường dẫn khép kín Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu Options Tùy chọn @@ -4079,6 +4083,8 @@ mark-style Japanese Kiểu Nhật Type on a Path Chữ trên đường dẫn Type on a Path Options Tùy chọn chữ trên đường dẫn Type on a Path Options… Tùy chọn chữ trên đường dẫn… + Center on Top Căn giữa ở trên + Center on Bottom Căn giữa ở dưới Type on a Path Tool Công cụ Chữ trên đường dẫn Type Orientation Hướng chữ Type stays text, and every stroke is a line with its lineweight and dashes. Chữ vẫn là văn bản, và mỗi nét là đường thẳng với độ dày và nét đứt. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..a5d29edb93 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2235,6 +2235,10 @@ file-filter Templates 模板 Optical Margin Alignment 视觉边界对齐 Dynamic Text 动态文本 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 开放路径或复合路径上的路径文字无法在顶部或底部居中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路径没有长度或没有高度时,其路径文字无法居中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串接文本中的路径文字无法在顶部或底部居中:{frames} +@msg select type on a closed path 请选择闭合路径上的路径文字 Optimize for fast web view 优化快速 Web 查看 Optimized 优化 Options 选项 @@ -3538,6 +3542,8 @@ digits Malayalam 马拉雅拉姆数字 Type on a Path 路径文字 Type on a Path Options 路径文字选项 Type on a Path Options… 路径文字选项… + Center on Top 在顶部居中 + Center on Bottom 在底部居中 Type on a Path Tool 路径文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字维持为文字,每个描边都是带有线宽与虚线的线条。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..7c9b433b5e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3307,6 +3307,10 @@ file-filter Templates 範本 Optical Margin Alignment 視覺邊界對齊 Dynamic Text 動態文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 開放路徑或複合路徑上的路徑文字無法在頂端或底端置中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路徑沒有長度或沒有高度時,其路徑文字無法置中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串連文字中的路徑文字無法在頂端或底端置中:{frames} +@msg select type on a closed path 請選取封閉路徑上的路徑文字 Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 @@ -4655,6 +4659,8 @@ digits Malayalam 馬來亞拉姆數字 Type on a Path 路徑文字 Type on a Path Options 路徑文字選項 Type on a Path Options… 路徑文字選項… + Center on Top 在頂端置中 + Center on Bottom 在底端置中 Type on a Path Tool 路徑文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字維持為文字,每個筆畫都是帶有線寬與虛線的線條。 diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a449fb0361..d38e16cffd 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -351,6 +351,58 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert!(!enabled(&app, id), "type on a path in a thread"); } +/// Type › Type on a Path › Center on Top and Center on Bottom run `type.pathCenter.top` and +/// `.bottom`, which the command palette also runs without params, and are enabled for type on a +/// closed path only. +#[test] +fn center_on_top_and_bottom_are_type_on_a_path_items() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let items: Vec<(&str, &str)> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Sub("Type on a Path", children) => Some(children), + _ => None, + }) + .flatten() + .filter_map(|item| match item { + Item::Cmd(label, id, Value::Null) if id.starts_with("type.pathCenter") => Some((label, id)), + _ => None, + }) + .collect(); + let ids = ["type.pathCenter.top", "type.pathCenter.bottom"]; + assert_eq!(items, [("Center on Top", ids[0]), ("Center on Bottom", ids[1])]); + let palette = crate::palette::items(); + for (label, id) in items { + assert!(palette.iter().any(|(l, i, _)| l == &format!("Type › Type on a Path › {label}") && i == id), "{label}"); + } + let line = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); + let open = app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on an open path"); + let square = app.run("path.create", json!({"d": "M60 200 L160 200 L160 300 L60 300 Z"})).unwrap()["id"].clone(); + let closed = app.run("text.createInPath", json!({"path": square, "mode": "onPath", "text": "Round", "at": [60, 200]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [closed]})).unwrap(); + assert!(ids.iter().all(|id| enabled(&app, id))); + app.run("select.set", json!({"ids": [closed, open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on a closed path and on an open one"); + // A palette pick runs the command with no params. + app.run("select.set", json!({"ids": [closed]})).unwrap(); + crate::palette::run_pick(&mut app, ids[0]); + let doc = &app.session.active().unwrap().doc; + let start = match &doc.node(vectorcraft_doc::NodeId(closed.as_u64().unwrap())).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => match t.kind { + vectorcraft_doc::TextKind::OnPath { start, .. } => start, + ref k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + // Once round, the middle of the top edge an eighth of the way round. + assert!((start - 0.625).abs() < 1e-6, "{start}"); +} + /// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. #[test] fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 0c46950f37..3a0996c819 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -396,7 +396,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> { PATH_EFFECTS .iter() .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) - .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) + .chain([ + Sep, + c("Center on Top", "type.pathCenter.top"), + c("Center on Bottom", "type.pathCenter.bottom"), + Sep, + c("Type on a Path Options…", "type.pathOptions"), + ]) .collect(), ), c("Dynamic Text", "type.dynamicText"), diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 53042baa69..4617f9ddeb 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -208,6 +208,34 @@ fn type_on_a_path_offers_dynamic_text() { assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Type on a closed path offers Center on Top and Center on Bottom; type on an open path does not. +#[test] +fn type_on_a_closed_path_offers_center_on_top_and_bottom() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let line = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()); + let open = id(app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()); + select(&mut app, &[open]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Type on a Path Options") && !has(&texts, "Center on Top") && !has(&texts, "Center on Bottom"), "{texts:?}"); + let circle = id(app.run("path.create", json!({"d": "M100 300 A100 100 0 1 1 300 300 A100 100 0 1 1 100 300 Z"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": circle, "mode": "onPath", "text": "Round", "at": [90, 300]})).unwrap()); + select(&mut app, &[t]); + let start = |app: &VectorcraftApp| match kind(app, t) { + NodeKind::Text(t) => match t.kind { + TextKind::OnPath { start, .. } => start, + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Center on Top") && has(&texts, "Center on Bottom"), "{texts:?}"); + click(&mut app, "Center on Top"); + assert!((start(&app) - 0.75).abs() < 1e-3, "{}", start(&app)); + click(&mut app, "Center on Bottom"); + assert!((start(&app) - 0.25).abs() < 1e-3, "{}", start(&app)); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec<vectorcraft_geom::Anchor> { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..8a9f1db840 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7301,6 +7301,26 @@ begins or ends, dragging the centre bracket slides the type along its path and, {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` +`type.pathCenter.top {ids?|id?}` and `type.pathCenter.bottom {ids?|id?}` (Type › Type on a Path › Center on Top and +Center on Bottom, and the Contextual Task Bar for type on a closed path) slide the selected type on a closed path along +it so that the middle of the span between its brackets lands on the path's topmost or bottommost point. This is +VectorCraft's own behavior. The path is measured as the object is drawn, after its transform, so a rotated, scaled or +skewed circle centers on its drawn top. On a level stretch, such as a rectangle's top edge, the middle of the stretch +is used; with several separate topmost points, the middle of the shortest part of the path that holds them all is +used. The span keeps its length, and type that runs once round stays once round. The first paragraph's alignment +becomes Center unless it is Center or Justify Center, so the type is centered there. The type is not flipped: on the +bottom of a circle, whose path runs clockwise, it reads upside down outside the circle until `type.pathOptions {flip: +true}` turns it inside. Point type and area type in the selection are left as they are. Type on an open or compound +path, type in a thread, and type on a path with no length or no height are refused with an error that names them, +and nothing changes. The menu items are enabled, and the task bar shows them, only while every selected type on a +path is on one closed path and none of it is in a thread. Several objects move in one undo step. When every object is +already centered there, nothing changes and no undo step is recorded. Each replies `{ids, start, end}` with the first +object's brackets. + +```json +{"name":"run_command","arguments":{"command":"type.pathCenter.top","params":{}}} +``` + ## Dynamic Text `type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..b27ef671ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1157,6 +1157,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. 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 › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`). 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`). 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. 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 18/72] 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 19/72] 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 20/72] 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 21/72] 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 386c444ff47c11d44c3bf57900ade8de09250d96 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:31:25 -0800 Subject: [PATCH 22/72] 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 123c9b5e7db6801e8b5c96015a31093fd9216d09 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:33:53 -0800 Subject: [PATCH 23/72] 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 68cbf041a4b67212341379ee8850bc4e861ef9b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:50:00 -0800 Subject: [PATCH 24/72] 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 25/72] 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 4f08f19ce4bdfd7d6d9236c53e2d39107a626a42 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:10:02 -0800 Subject: [PATCH 26/72] 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 27/72] 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 e609b26a95946f0330bc87ce3cb938ad9310767f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:55:07 -0800 Subject: [PATCH 28/72] =?UTF-8?q?Warp:=20bend=20each=20style=20as=20one=20?= =?UTF-8?q?B=C3=A9zier=20patch,=20and=20keep=20saved=20warps'=20look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the fifteen Warp styles now bends the bounds as one Bézier patch. The style sets the patch's control points from the bend and the size of the bounds, the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales each row. The Warp effects and warp envelopes share the map (`WarpNet`). Content with no width or no height takes the middle of the patch along that side. A warp carries the map it draws with as `version`. New warps, the built-in graphic styles' Warps and warps opened from .ai files get 1. Warps in .vectorcraft documents saved before the version existed read as 0 and keep the earlier map, so those documents keep their look. Edits keep a warp's version, and Warp Options editing several objects keeps each one's own. effect.apply, effect.setParams and the warp envelope commands take `version` 0 or 1 (`1.0` counts as 1) and refuse any other value. An .ai file holds a warp's settings and not its version, so a version 0 warp saved to .ai opens again with version 1. The .ai writer compares a style's settings without the version, so saved warps still export as editable effects. Docs: docs/mcp.md (Envelopes), docs/effects-parity.md (Warp), docs/target-app-parity.md. --- crates/doc/src/hit/tests.rs | 4 +- crates/doc/src/live.rs | 198 +++++++++++- crates/doc/src/live/envelope.rs | 8 + crates/doc/src/live/mesh.rs | 13 +- crates/doc/src/live/warp.rs | 331 ++++++++++++++++++++ crates/doc/src/style_libs.rs | 28 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/lib.rs | 2 +- crates/effects/src/params.rs | 27 +- crates/effects/src/tests.rs | 51 +++ crates/effects/src/warp.rs | 35 ++- crates/engine/src/cmd/live/commands.rs | 8 +- crates/engine/src/cmd/live/envelope.rs | 17 +- crates/engine/src/cmd/params.rs | 8 + crates/engine/src/tests/effectedit.rs | 34 ++ crates/engine/src/tests/envelope.rs | 49 ++- crates/engine/src/tests/envelope_distort.rs | 7 +- crates/engine/src/tests/live.rs | 4 +- crates/eps/src/ai_editing/tests.rs | 18 ++ crates/eps/src/ai_editing/writer/styles.rs | 9 +- crates/eps/src/import/ai/envelope.rs | 1 + crates/eps/src/import/ai/read/directives.rs | 2 +- crates/eps/src/import/ai/style.rs | 1 + crates/eps/src/import/ai/style/tests.rs | 2 + crates/eps/src/import/ai/tests.rs | 14 +- crates/eps/src/import/tests_native.rs | 9 +- crates/render/src/live.rs | 4 +- crates/render/src/tests/fontchange.rs | 6 +- crates/render/src/tests/memory.rs | 4 +- crates/render/src/tests/objectfx.rs | 2 +- crates/render/src/tests/placed_document.rs | 2 +- crates/tools/src/xform/eyedropper.rs | 4 +- crates/ui-egui/src/dialogs/effect.rs | 7 + crates/ui-egui/src/dialogs/envelope.rs | 2 +- crates/ui-egui/src/dialogs/tests_warp.rs | 30 ++ crates/ui-egui/src/dialogs/warp_effect.rs | 8 +- docs/effects-parity.md | 2 +- docs/mcp.md | 14 + docs/target-app-parity.md | 1 + 39 files changed, 906 insertions(+), 62 deletions(-) diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 2c0a6984d0..3eac4202b6 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { envelope, NodeKind::Envelope { content, - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: false, options: Default::default(), @@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() { d.alloc_id(), NodeKind::Envelope { content: vec![inner(a), inner(b)], - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: true, options: Default::default(), diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304c4e70f6..983b93419b 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -65,7 +65,7 @@ pub use expand::{ #[cfg(test)] use mesh::grid_eval; pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; -pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version}; #[cfg(test)] mod tests { @@ -132,6 +132,200 @@ mod tests { let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI)); assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12); } + + /// Each warp style's Bézier net, at points worked out by a separate implementation of the same + /// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) → + /// the point in the box (origin at its top left, y down). + #[test] + fn warp_nets_bend_each_style_as_its_own_patch() { + #[rustfmt::skip] + let samples = [ + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045), + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443), + ("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666), + ("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371), + ("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712), + ("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703), + ("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585), + ("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631), + ("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44), + ("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792), + ("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352), + ("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48), + ("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472), + ("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68), + ("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68), + ("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224), + ("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769), + ]; + for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples { + let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") }; + let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v); + assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})"); + } + } + + /// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half + /// circle through the bottom corners about the bottom's middle, and the top edge the one 100 + /// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle. + #[test] + fn warp_net_arc_edges_are_concentric_circles() { + let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false); + let center = Point::new(100.0, 100.0); + for i in 0..=20 { + let u = f64::from(i) / 20.0; + for (v, r) in [(0.0, 200.0), (1.0, 100.0)] { + let d = net.at(u, v).distance(center); + assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}"); + // Its ends and middle lie on the circle. + if i % 10 == 0 { + assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}"); + } + } + } + // Bend 0 leaves the box as it is. + for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] { + let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false); + assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}"); + } + } + + /// The horizontal distortion scales each column of the net about its middle (narrowing the left + /// side for a positive value), then the vertical one each row (narrowing the top). + #[test] + fn warp_net_distortion_scales_the_columns_then_the_rows() { + let corners = |dh: f64, dv: f64| { + let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false); + [net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)] + }; + let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9); + assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0)); + assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5)); + // Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and + // it then scales by 1.4 about its middle, (100, 0). + assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4)); + } + + /// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number + /// counts as 0, and out of range as its limit; a size that isn't a number as 0. + #[test] + fn warp_net_takes_any_numbers() { + for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] { + for (b, dh, w, h) in + [(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)] + { + for vertical in [false, true] { + let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6); + assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}"); + } + } + } + let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false); + assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false)); + } + + /// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only + /// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's + /// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number, + /// gives finite points inside the box and past it, and the unit square's points scaled up where + /// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite + /// width and a height of 1e-300 or 100 every y is the unit square's times the height, also + /// where x goes past the largest finite number. + #[test] + fn warp_net_bends_a_huge_box_without_overflowing() { + let w = 1e308; + let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false); + let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false); + for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] { + let (p, q) = (flag.at(u, v), small.at(u, v)); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}"); + assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}"); + } + let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false); + assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300)); + let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false); + for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] { + let p = fisheye.at(u, v); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + } + let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE); + let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1)); + assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}"); + // The bottom of a box with a subnormal height stays exactly at that height: the net is + // built on the box halved, and the height's lowest bit but one is set. + let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2); + let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0); + assert_eq!(sub.y, low, "{sub:?}"); + for h in [1e-300, 100.0] { + let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false); + for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] { + let (p, q) = (tall.at(u, v), unit.at(u, v)); + assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}"); + } + } + } + + /// Content with no width or no height takes the middle of a warp envelope's patch along that + /// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight. + #[test] + fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() { + let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let arc = warp("arc"); + let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0)); + for y in [0.0, 30.0, 100.0] { + let p = map(Point::new(100.0, y)); + assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}"); + } + let bulge = warp("bulge"); + let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0)); + for x in [0.0, 70.0, 200.0] { + let p = map(Point::new(x, 50.0)); + assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}"); + } + } + + /// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written + /// as none, so files keep their bytes, and the nets' version is written. + #[test] + fn warp_envelopes_write_their_map_version_only_when_it_has_one() { + let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap(); + assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}"); + assert!(!serde_json::to_string(&old).unwrap().contains("version")); + let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let text = serde_json::to_string(&new).unwrap(); + assert!(text.contains(r#""version":1"#), "{text}"); + assert_eq!(serde_json::from_str::<EnvelopeKind>(&text).unwrap(), new); + } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> { @@ -537,7 +731,7 @@ mod tests { #[test] fn an_envelope_maps_from_its_box_with_its_margins() { let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art()); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = [Arc::new(rect)]; let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src(); // Top, bottom, left and right. diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 8abe961841..f197837020 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of}; use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; use vectorcraft_geom::Homography; +fn is_zero(n: &u32) -> bool { + *n == 0 +} + /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -19,6 +23,10 @@ pub enum EnvelopeKind { v: f64, #[serde(default = "yes")] horizontal: bool, + /// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net, + /// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point). + #[serde(default, skip_serializing_if = "is_zero")] + version: u32, }, /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. Mesh { diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index c59374dee9..be865e2769 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1,5 +1,5 @@ use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; -use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; +use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point}; use super::{ Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color, one, @@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> { let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); match kind { - EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => { let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); let b = bend.clamp(-100.0, 100.0) / 100.0; let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + if uses_net(*version) { + let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal); + // Content with no width or no height takes the middle of the patch along that side. + let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0); + return Box::new(move |u: f64, v: f64| { + let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v }); + frame * (src.origin() + net.at(u, v).to_vec2()) + }); + } let c = src.center(); let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); let horizontal = *horizontal; diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs index 6d7392ac5f..08bef91140 100644 --- a/crates/doc/src/live/warp.rs +++ b/crates/doc/src/live/warp.rs @@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6 (x2, y2) } +// ===================================================================================== +// Warp nets: each style as one Bézier patch over its box +// ===================================================================================== + +/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With +/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep +/// their look; new and imported warps are given this one. +pub const WARP_NET: u32 = 1; + +/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws +/// with the newest map it has. +pub fn uses_net(version: u32) -> bool { + version >= WARP_NET +} + +/// The map version `v` names when it is one this build draws: a whole number from 0 to +/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`). +pub fn warp_version(v: &serde_json::Value) -> Option<u32> { + let n = v.as_f64()?; + (n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32) +} + +/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row +/// from the top, in the box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug)] +struct Net { + rows: usize, + cols: usize, + p: [[Point; 4]; 4], +} + +impl Net { + /// The undistorted box `w` × `h` as a `rows` × `cols` net. + fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self { + let mut p = [[Point::ZERO; 4]; 4]; + for (r, row) in p.iter_mut().enumerate().take(rows) { + for (c, q) in row.iter_mut().enumerate().take(cols) { + *q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64)); + } + } + Self { rows, cols, p } + } + + /// Upside down within a box `h` high: the rows in reverse order, each point mirrored. + fn mirrored(self, h: f64) -> Self { + let mut out = self; + for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) { + for (q, f) in to.iter_mut().zip(from).take(self.cols) { + *q = Point::new(f.x, h - f.y); + } + } + out + } + + /// Rows and columns exchanged, and x with y: a net made on the box turned on its side. + fn transposed(self) -> Self { + let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] }; + for (r, row) in self.p[..self.rows].iter().enumerate() { + for (c, q) in row[..self.cols].iter().enumerate() { + out.p[c][r] = Point::new(q.y, q.x); + } + } + out + } + + /// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from + /// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1). + fn distort_columns(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for c in 0..cols { + let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0); + let mid = half_way(self.p[0][c], self.p[rows - 1][c]); + for row in &mut self.p[..rows] { + row[c] = mid + (row[c] - mid) * s; + } + } + } + + /// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0 + /// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1). + fn distort_rows(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for (r, row) in self.p[..rows].iter_mut().enumerate() { + let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0); + let mid = half_way(row[0], row[cols - 1]); + for q in &mut row[..cols] { + *q = mid + (*q - mid) * s; + } + } + } + + /// The same patch as a bicubic net (degree elevation, which leaves the surface as it is). + fn bicubic(self) -> [[Point; 4]; 4] { + let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect(); + let mut out = [[Point::ZERO; 4]; 4]; + for c in 0..4 { + let col: Vec<Point> = rows.iter().map(|row| row[c]).collect(); + for (to, q) in out.iter_mut().zip(cubic_line(&col)) { + to[c] = q; + } + } + out + } +} + +/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one. +fn half_way(a: Point, b: Point) -> Point { + Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y) +} + +/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form. +fn cubic_line(p: &[Point]) -> [Point; 4] { + match *p { + [a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b], + [a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c], + [a, b, c, d] => [a, b, c, d], + _ => [Point::ZERO; 4], + } +} + +/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord` +/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along +/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is +/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord. +fn arc_handles(chord: f64, b: f64) -> (f64, f64) { + let th = b.abs().min(1.0) * PI; + let len = chord / (3.0 * (th / 4.0).cos().powi(2)); + (len * (th / 2.0).cos(), len * (th / 2.0).sin()) +} + +/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive +/// `b` when `up`, down when not, the other way for a negative one. +fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] { + let (ax, ay) = arc_handles(w, b); + let dy = if (b > 0.0) == up { -ay } else { ay }; + [Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)] +} + +/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top +/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly +/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean +/// out by θ/2. +fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] { + let th = b.abs().min(1.0) * PI; + let (s, c) = (th / 2.0).sin_cos(); + let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c))); + let (ax, ay) = arc_handles(tr.x - tl.x, b); + let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr]; + let bottom = arc_row(w, h, b.abs(), true); + let mut out = [[Point::ZERO; 4]; 4]; + for (r, row) in out.iter_mut().enumerate() { + for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) { + *q = t.lerp(u, r as f64 / 3.0); + } + } + out +} + +/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion. +/// `vertical`: the net of the box turned on its side, where Rise moves the other half. +fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net { + use WarpStyle::*; + let two = |top: [Point; 4], bottom: [Point; 4]| { + let mut p = [[Point::ZERO; 4]; 4]; + (p[0], p[1]) = (top, bottom); + Net { rows: 2, cols: 4, p } + }; + let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y)); + match style { + Arc => { + let rows = arc_rows(w, h, b); + let net = two(rows[0], rows[3]); + if b < 0.0 { net.mirrored(h) } else { net } + } + ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)), + ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)), + Arch => { + let top = arc_row(w, 0.0, b, true); + two(top, top.map(|q| Point::new(q.x, q.y + h))) + } + Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)), + ShellLower | ShellUpper => { + let a = arc_rows(w, h, b); + let mut net = Net::flat(w, h, 4, 4); + if b >= 0.0 { + // The outer arc and the ends of the row inside it, upside down for Shell Lower. + net.p[0] = a[0]; + (net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]); + if style == ShellLower { net.mirrored(h) } else { net } + } else { + // The inner arc and the ends of the row outside it, upside down for Shell Upper. + net.p[3] = a[3]; + (net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]); + if style == ShellUpper { net.mirrored(h) } else { net } + } + } + Flag | Fish | Rise => { + let mut net = Net::flat(w, h, 2, 4); + let d = 2.0 * b * h; + match style { + Flag => { + for row in &mut net.p[..2] { + (row[1].y, row[2].y) = (row[1].y + d, row[2].y - d); + } + } + Fish => { + (net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d); + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + } + _ => { + for row in &mut net.p[..2] { + if vertical { + (row[2].y, row[3].y) = (row[2].y - d, row[3].y - d); + } else { + (row[0].y, row[1].y) = (row[0].y + d, row[1].y + d); + } + } + } + } + net + } + Wave => { + let mut net = Net::flat(w, h, 3, 4); + let d = 2.0 * b * h; + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + net + } + Fisheye => { + let mut net = Net::flat(w, h, 4, 4); + let c = Point::new(w / 2.0, h / 2.0); + for row in &mut net.p[1..3] { + for q in &mut row[1..3] { + *q = c + (*q - c) * (1.0 + 4.0 * b); + } + } + net + } + Inflate | Squeeze => { + let mut net = Net::flat(w, h, 3, 3); + let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 }; + net.p[0][1].y -= b * h / 2.0; + net.p[2][1].y += b * h / 2.0; + net.p[1][0].x -= side; + net.p[1][2].x += side; + net + } + Twist => { + // The four inner points move round: clockwise for a positive bend. + let mut net = Net::flat(w, h, 4, 4); + let (dx, dy) = (b.abs() * w, b.abs() * h); + if b >= 0.0 { + net.p[1][1].x += dx; + net.p[1][2].y += dy; + net.p[2][2].x -= dx; + net.p[2][1].y -= dy; + } else { + net.p[1][1].y += dy; + net.p[1][2].x -= dx; + net.p[2][2].y -= dy; + net.p[2][1].x += dx; + } + net + } + } +} + +/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns +/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the +/// box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpNet { + /// The control points over the box divided by `scale`. + net: [[Point; 4]; 4], + /// 1, or a power of two when the net over the box itself does not fit in finite numbers. + scale: f64, + w: f64, + h: f64, +} + +impl WarpNet { + /// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each + /// -1..1, clamped); `vertical` bends along the box's height. + pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self { + let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 }; + let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 }; + let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h)); + let build = |w: f64, h: f64| { + let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) }; + net.distort_columns(dh); + net.distort_rows(dv); + net.bicubic() + }; + // A box near the largest finite size can overflow the net. The net is then built on the box + // divided by the smallest power of two that fits (2 to 4096), an exact division for every + // side above the subnormal range, and points are multiplied back as they are read. + let mut out = Self { net: build(w, h), scale: 1.0, w, h }; + while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) { + let scale = out.scale * 2.0; + out = Self { net: build(w / scale, h / scale), scale, w, h }; + } + out + } + + /// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A + /// coordinate the patch would send past the largest finite number is the box's own, held to + /// the finite range. + pub fn at(&self, u: f64, v: f64) -> Point { + let p = self.at_scaled(u, v); + let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) }; + let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) }; + Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h)) + } + + /// [`Self::at`] divided by the net's scale. + fn at_scaled(&self, u: f64, v: f64) -> Point { + let bern = |t: f64| { + let s = 1.0 - t; + [s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t] + }; + let (bu, bv) = (bern(u), bern(v)); + let (mut x, mut y) = (0.0, 0.0); + for (row, wv) in self.net.iter().zip(bv) { + for (q, wu) in row.iter().zip(bu) { + x += wv * wu * q.x; + y += wv * wu * q.y; + } + } + Point::new(x, y) + } +} + /// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { let c = sp.segment(i); diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..5b90427162 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -192,8 +192,14 @@ fn round(h: &str, width: f64) -> StrokeLayer { StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) } } -/// Live effect `id` with `params` (the others keep the effect's defaults). -fn fx(id: &str, params: Value) -> Effect { +/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its +/// style's Bézier net, as a new Warp does. +fn fx(id: &str, mut params: Value) -> Effect { + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::live::WARP_NET)); + } Effect { id: id.into(), params, visible: true } } @@ -552,6 +558,24 @@ mod tests { assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); } + /// The built-in styles' Warps draw with the map a new Warp takes. + #[test] + fn builtin_warp_styles_take_the_nets_map() { + let mut warps = 0; + for b in STYLE_LIBRARIES { + let lib = builtin_style_library(b.id).unwrap(); + for g in &lib.styles { + let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect(); + effects.extend(g.appearance.items.iter().flat_map(|i| i.effects())); + for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) { + assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id); + warps += 1; + } + } + } + assert!(warps >= 3, "{warps}"); + } + #[test] fn vcstyles_round_trip() { let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone(); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5c18f2d5f0..a827bea0e2 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..6165aa9a9b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -94,7 +94,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; pub use vectorcraft_three_d::Mesh; -pub use warp::{WarpStyle, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version}; mod catalog; mod geometry; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index 7785aff335..1e3c8c1d31 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its /// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of /// then) become the current ones that show exactly the same, unless those are given too, which win. -/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's +/// map `version` given as a whole float is stored as an integer. pub fn upgrade_params(id: &str, params: &Value) -> Value { match params { Value::Object(m) => { @@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value { upgrade_perspective(id, &mut m); upgrade_light(id, &mut m); upgrade_scribble(id, &mut m); + upgrade_warp_version(id, &mut m); Value::Object(m) } other => other.clone(), @@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) { } } +/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer. +fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) { + if !id.starts_with("warp.") { + return; + } + if let Some(v) = m.get_mut("version") + && !v.is_u64() + && let Some(n) = crate::warp::warp_version(v) + { + *v = json!(n); + } +} + fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) { if id != "stylize.scribble" { return; @@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> { if !catalog_index().contains_key(id) { vectorcraft_plugins::effect::installed(id)?; } - Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) + let mut params = merged_params(id, params); + // A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry + // no version, so warps saved without one keep the earlier map. + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::warp::WARP_NET)); + } + Some(Effect { id: id.to_string(), params, visible: true }) } /// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 532251bd73..adc518043e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() { assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}"); } +/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as +/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the +/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map +/// moves it 0.6 × 25 = 15. +#[test] +fn a_warp_draws_with_the_map_its_version_names() { + let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0)); + // u(1 - u)(1 - 2u) peaks at √3 / 18. + let peak = 360.0 * 3f64.sqrt() / 18.0; + for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] { + let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap(); + assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}"); + assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}"); + } +} + +/// A straight line has a box with no width or no height, and the net bends it as the middle of +/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye, +/// Inflate and Squeeze a horizontal line stays straight. +#[test] +fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() { + let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>(); + let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0)); + let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical); + assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}"); + let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0)); + for style in ["bulge", "fisheye", "inflate", "squeeze"] { + let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal); + assert!( + anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)), + "{style}: {out:?}" + ); + } +} + +/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none, +/// so a saved warp's params don't gain one. +#[test] +fn new_warps_take_the_nets_version() { + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}"); + let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(0), "{id}"); + assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}"); + } + let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") }; + assert!(e.params.get("version").is_none()); +} + #[test] fn transform_copies_and_move() { let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square()); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 84f196a8b4..f607abacdf 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -1,14 +1,22 @@ //! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion. //! -//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is -//! split into short pieces whose control points are mapped (see `map_nonlinear`). +//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]); +//! one without (saved before the version existed) is a map on normalized box coordinates +//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control +//! points are mapped (see `map_nonlinear`). use serde_json::Value; use vectorcraft_geom::{PathData, Point, Rect}; use crate::util::{map_nonlinear_within, num, text}; -pub use vectorcraft_doc::live::{WarpStyle, warp_point}; +pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version}; + +/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent. +pub fn map_version(p: &Value) -> u32 { + let v = num(p, "version", 0.0); + if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 } +} /// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { @@ -16,6 +24,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); + let piece = b.width().hypot(b.height()) / 24.0; + if uses_net(map_version(p)) { + let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical); + let (w, h) = (b.width(), b.height()); + // A box with no width or no height takes the middle of the patch along that side. + let f = |q: Point| { + let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 }); + b.origin() + net.at(u, v).to_vec2() + }; + return map_nonlinear_within(path, piece, max, f); + } let c = b.center(); let hw = (b.width() / 2.0).max(1e-9); let hh = (b.height() / 2.0).max(1e-9); @@ -29,17 +48,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - }; Point::new(c.x + x2 * hw, c.y + y2 * hh) }; - let piece = b.width().hypot(b.height()) / 24.0; map_nonlinear_within(path, piece, max, f) } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with -/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the -/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` -/// tests both sides of it). +/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as +/// none: the threshold must match the one in [`warp_point`]'s Arc case +/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any +/// bend moves the outline. pub(crate) fn bends(id: &str, p: &Value) -> bool { let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; let bend = share("bend", 50.0); - let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 }; bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 } diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869d..005d892639 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -136,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", has_selection, env_make_warp ), @@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reset with Warp…", ["Object", "Envelope Distort"], None, - "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content", + "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content", has_envelope, env_reset_warp ), @@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> { "Envelope Info", [], None, - "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", has_doc, env_info ), @@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", has_doc, env_options ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..a91fd8edd0 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> { check_numbers(p, WARP_NUMBERS, cmd)?; - let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); - if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { - (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); + // A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version` + // names one. + let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET); + if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur { + (style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0); + } + if let Some(n) = p.get("version") { + version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?; } if let Some(s) = text_param(p, "style", cmd)? { let s = s.strip_prefix("warp.").unwrap_or(s); @@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop Some(o) => o == "horizontal", None => given, }; - Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) + Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version }) } /// The options and fidelity new envelopes get (Envelope Options with no envelope selected). @@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> { return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; let v = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { - json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version}) } EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 4b8ae5d2be..60fa90c32c 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); } + // A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net. + let newest = vectorcraft_render::effects::WARP_NET; + if effect.starts_with("warp.") + && let Some(v) = given.get("version") + && vectorcraft_render::effects::warp_version(v).is_none() + { + return Err(bad(id, format!("`version` must be 0 or {newest}"))); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/tests/effectedit.rs b/crates/engine/src/tests/effectedit.rs index 2d10c3f5d9..34e01bf31e 100644 --- a/crates/engine/src/tests/effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -147,3 +147,37 @@ fn show_all_hidden_attributes() { s.execute("edit.undo", &json!({})).unwrap(); assert!(node(&s, id).appearance.has_hidden()); } + +/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit +/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and +/// a map this build doesn't draw is refused. +#[test] +fn warps_keep_the_map_their_version_names() { + let (mut s, id) = session_with_rect(); + let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned(); + run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}})); + assert_eq!(version(&s), None); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}})); + assert_eq!(version(&s), Some(json!(1))); + run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + // A whole number given as a float is that version. + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1)); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1")] { + assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}"); + assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}"); + } + assert_eq!(node(&s, id).appearance.effects.len(), 2); +} diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index e4d030d387..4ffd7a361a 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err()); } +/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps +/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its +/// warp or its options change. +#[test] +fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() { + // The surface point a quarter of the way along the top edge. + let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p; + let at = |x, y| vectorcraft_geom::Point::new(x, y); + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40})); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET)); + // Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4. + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e)); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind) + && let EnvelopeKind::Warp { version, .. } = &mut **kind + { + *version = 0; + } + s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap(); + s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + // The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights. + assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e)); + // `version` moves a warp to a map, and a new warp can take the earlier one. + s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0})); + assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] { + for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] { + let r = s.execute(cmd, &json!({"version": bad})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}"); + } + } +} + #[test] fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let mut s = session(); @@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let content = node(&s, a); assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; - // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. + // The mesh follows the arc: its top corners swing out past the content's sides and up. let b = m.outline().bounds().unwrap(); - assert!(b.y1 > 201.0, "{b:?}"); + assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}"); assert_eq!(s.doc().unwrap().selection.objects, ids); } diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 8a1e46c68a..2dbef41238 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -286,17 +286,18 @@ fn symbols_bend_with_the_envelope() { let e = arch(&mut s, 60.0, json!({})); let pts = anchors(&evaluated(&s, e)); assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle"); - // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). + // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The + // arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it. let mut r = vectorcraft_render::Renderer::new(); let region = Rect::new(0.0, 0.0, 400.0, 300.0); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60); s.edit("edit symbol", |d, _| { let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default()); d.symbols[0].art = Arc::new(art); Ok(()) }) .unwrap(); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200); } fn strokes(nodes: &[Node]) -> usize { diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 9fc790efa2..33cc5749e1 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -483,8 +483,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() { let pts = all_anchors(&out); assert!(pts.len() > 4, "curves are subdivided"); assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}"); - // It really is bent: the sides moved down. - assert!(pts.iter().any(|p| p.y > 201.0)); + // It really is bent: the top corners swung out past the sides. + assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0)); } #[test] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a99646c9c2..6549004c7d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6627,6 +6627,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() { for id in &ids { cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1}))); } for (id, params) in cases { let effect = fx(id, params.clone()); @@ -6643,6 +6644,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() { let got = &back.graphic_styles[0].appearance.effects; assert_eq!(got.len(), 1, "{id} {params}"); assert_eq!(got[0].id, effect.id); + // A warp read from a file bends as its style's Bézier net. + if id.starts_with("warp.") { + assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}"); + } let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); for (key, value) in w.as_object().unwrap() { // Random isn't written, and a style with it isn't read; its seed is the default. @@ -6663,6 +6668,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() { } } } + // A warp saved before the map version existed is written with its settings too, and reads back on + // the nets' map. + let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true }; + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) }); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0))); + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. let mut doc = Document::new(200.0, 100.0); let effects = vec![ diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index c4ae9fda3b..c28c538bcf 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -889,9 +889,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> } /// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as -/// `#rrggbb`. +/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader +/// gives every warp it reads the newest map, so a warp saved before the version existed is written +/// with its settings as before. fn normalized_params(e: &Effect) -> Value { let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if e.id.starts_with("warp.") + && let Value::Object(m) = &mut p + { + m.remove("version"); + } if let Some(m) = word(&p, "mode") { p["mode"] = Value::String(m); } diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 8b3fceecf3..50674860a6 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -244,6 +244,7 @@ fn warp(o: &Obj) -> Option<EnvelopeKind> { h: percent("DeformHoriz")?, v: percent("DeformVert")?, horizontal: !vertical, + version: vectorcraft_doc::live::WARP_NET, }) } diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f7df4f35d6..911d8340cc 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1259,7 +1259,7 @@ mod tests { fn envelopes_in_symbol_definitions_spend_the_imports_checks() { use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys}; use vectorcraft_doc::live::EnvelopeKind; - let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET }; let envelope = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want)); let mut reader = Reader::new(b""); diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f15744f30c..ef661c1446 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> { "horizontal": pct("DeformHoriz"), "vertical": pct("DeformVert"), "orientation": if flag("Rotate") { "vertical" } else { "horizontal" }, + "version": vectorcraft_doc::live::WARP_NET, }), ) } diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..47086ca171 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { // The warp is the whole object's; the characters paint below every item. let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + // A file's warp bends as its style's Bézier net. + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); assert_eq!(st.contents_index, None); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 46386d2200..8fa0a58346 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use kurbo::{Affine, Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::live::WARP_NET; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; use super::*; @@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() { for (style, bend, h, v, rotate, id) in [(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")] { - let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 }; + let want = + EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want)); let r = read_ok(&layer(&body)); let a = art(&r.doc); assert_eq!(a.len(), 1, "{a:#?}"); let (content, kind, fidelity, editing, options) = live_envelope(&a[0]); - let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") }; - assert_eq!((s.as_str(), *horizontal), (id, rotate == 0)); + let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") }; + assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET)); assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}"); assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW)); assert_eq!(content.len(), 1, "{content:#?}"); @@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() { "twist", ]; for (n, id) in ids.iter().enumerate() { - let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0); let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want)))); let a = art(&r.doc); @@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() { #[test] fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other)); let r = read_ok(&layer(&body)); let a = art(&r.doc); @@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; const WHITE: &str = "0 0 0 0 1 1 1 Xa\n"; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let content = envelope_content().replace(RED, WHITE); let drawn = drawn_content(&other).replace(RED, WHITE); let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..7353c5c190 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String { fn arc_envelope(text: &Node, bend: f64) -> Node { let mut text = text.clone(); text.visible = true; - let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::live::EnvelopeKind::Warp { + style: "arc".into(), + bend, + h: 0.0, + v: 0.0, + horizontal: true, + version: vectorcraft_doc::live::WARP_NET, + }; Node::new( vectorcraft_doc::NodeId(1_000_000), NodeKind::Envelope { diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 3f5ebbb2fe..2b6610beb1 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -781,7 +781,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) }); } let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY }); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(first)], kind: Box::new(kind), @@ -815,7 +815,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() }); } let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY }))); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: content.to_vec(), kind: Box::new(kind), diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 8f6d68f575..0e7864b0b9 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() { let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) }); let l = d.layers[0].id; for (i, content) in [typed, shown].into_iter().enumerate() { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], kind: Box::new(kind), @@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { let mut d = Document::new(300.0, 150.0); let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() }; let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style)))); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(typed)], kind: Box::new(kind), @@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { const FAMILY: &str = "Mapped Serif 4"; let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = Node::new( vectorcraft_doc::NodeId(1), NodeKind::Envelope { diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index d5e6919f0d..f95735baec 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() { d.patterns.push(def); let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0)); let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() }; let envelope = Node::new( d.alloc_id(), @@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec<Node>, patterns: bool) -> Node { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() }; let content = content.into_iter().map(Arc::new).collect(); Node::new( diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index fe1e5ee1d1..7e4007c6ac 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] { /// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no /// bend), with Distort Appearance on or off. fn envelope_doc(content: Node, distort_appearance: bool) -> Document { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], diff --git a/crates/render/src/tests/placed_document.rs b/crates/render/src/tests/placed_document.rs index c3c00a4cf0..3a65b1abe0 100644 --- a/crates/render/src/tests/placed_document.rs +++ b/crates/render/src/tests/placed_document.rs @@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() { let placed = d.remove(NodeId(50)).unwrap(); let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") }; let p = (**p).clone(); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![placed], kind: Box::new(kind), diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index db3aad793e..32e1df2a16 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -1543,7 +1543,7 @@ mod tests { let d = doc_with_live_over_green(|d| { let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0); let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let n = Node::new( d.alloc_id(), @@ -1574,7 +1574,7 @@ mod tests { use vectorcraft_geom::{Affine, Rect}; let d = doc_with_live_over_green(|d| { let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let options = Default::default(); Node::new( diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 3a58ee3a9e..9e5c531c94 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -879,6 +879,13 @@ fn command(d: &Dialog) -> (&'static str, Value) { p["item"] = item.unwrap_or(Value::Null); p["params"] = params; let id = d.str("__effect"); + // Each target keeps the map its own Warp draws with: the dialog's `version` is the first + // target's. + if super::warp_effect::is_warp(&id) + && let Some(Value::Object(m)) = p.get_mut("params") + { + m.remove("version"); + } // The displayed style comes from the first target. Preserve other targets' Warp IDs // unless the user deliberately chooses Style, including after a preview rollback. if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") { diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index f537595ebc..6c9d95b601 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; let bar = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols }, diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index e7cbfcc5b7..21d3e8e1a4 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() { assert_eq!(app.session.doc().unwrap().doc, moved); } } + +/// Warp Options editing the Warps of two objects, one new and one saved without a map version, +/// changes the Bend of both and keeps each one's own `version`. +#[test] +fn warp_options_edit_keeps_each_targets_map_version() { + let (mut app, first) = app(); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap(); + app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS); + let ctx = context(); + settle(&mut app, &ctx); + click_text(&mut app, &ctx, "Edit"); + settle(&mut app, &ctx); + make_warp_edit(&mut app, &ctx, "bend"); + click_text(&mut app, &ctx, "OK"); + assert!(app.ui.dialog.is_none()); + let doc = app.session.doc().unwrap().doc.clone(); + let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone(); + assert_eq!((¶ms(first)["bend"], ¶ms(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET))); + assert_eq!(params(second)["bend"], json!(25.0)); + assert!(params(second).get("version").is_none(), "{:?}", params(second)); +} diff --git a/crates/ui-egui/src/dialogs/warp_effect.rs b/crates/ui-egui/src/dialogs/warp_effect.rs index ff530fbdf2..e8ef2bb6b7 100644 --- a/crates/ui-egui/src/dialogs/warp_effect.rs +++ b/crates/ui-egui/src/dialogs/warp_effect.rs @@ -1,7 +1,7 @@ //! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema. use egui::{Pos2, Rect, Stroke, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point}; +use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle}; use super::form; use crate::state::Dialog; @@ -38,6 +38,8 @@ fn shown(label: &str) -> String { fn style_preview(ui: &mut egui::Ui, suffix: &str) { let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover()); let Some(style) = WarpStyle::from_id(suffix) else { return }; + // The map new warps take, on a box 0.55 times as high as it is wide. + let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false); let mut lines = vec![]; let mut bounds = Rect::NOTHING; for vertical in [false, true] { @@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) { .map(|i| { let along = -1.0 + f64::from(i) / 8.0; let (x, y) = if vertical { (fixed, along) } else { (along, fixed) }; - let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y); - let p = Pos2::new(x as f32, y as f32); + let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0); + let p = Pos2::new(q.x as f32, q.y as f32); bounds.extend_with(p); p }) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..c3aa528e54 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -28,7 +28,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `<filter>` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | -| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | +| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers its resolution, PDF export warns once per object with the requested and the lowest achieved ppi diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..8003354a7a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8238,6 +8238,20 @@ share of the content's width or height in proportion to the length of its contro its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them. +A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and +the size of the bounds (Arc's top and bottom edges, for example, are cubic curves close to arcs around one center), then +the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales +each row. Content with no width or no height takes the middle of the patch along that side. New warp envelopes, and +those opened from `.ai` files, carry `version` 1 for this map, and `object.envelope.info` reports it. Envelopes in +`.vectorcraft` documents saved before the version existed read as `version` 0, the earlier map, and keep their look, +and editing an envelope keeps its version. `version` (0 or 1) on `object.envelope.makeWithWarp`, +`object.envelope.resetWithWarp` and `object.envelope.options` sets the map. The Warp effects (`warp.arc` … +`warp.twist`) take the same map through the same `version` param: `effect.apply` gives a new Warp `version` 1 unless +`params.version` is 0, the built-in graphic styles' Warps have 1, and Effect › Warp edits keep each Warp's own version. +These commands refuse any other value with `` `version` must be 0 or 1 ``; `1.0` counts as 1. An `.ai` file holds a +warp's settings and not its version, so a `version` 0 warp saved to `.ai` and opened again draws with `version` 1. +These patches are VectorCraft's own rules; the styles and the ranges of Bend and the distortions are those of the Warp +options (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). `object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected, `object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf4c0df1a9..f4c35ddd41 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -588,6 +588,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. 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 29/72] 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 d5044de8b15489af65ee63bc770e040b3011a524 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:59:09 -0800 Subject: [PATCH 30/72] 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 987dd18aff291b18cc5968a4583683eb83ab5864 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:41:25 -0800 Subject: [PATCH 31/72] 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 f526c5310c2743fed82d9d5f8dcc7fd4df4e3423 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:01:32 -0800 Subject: [PATCH 32/72] 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 c74821031e6d1e372d50f15daa896efceb31a6c8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:56:02 -0800 Subject: [PATCH 33/72] 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 d69e9c10d7d21f4ade82955ef8c80643deb1fb42 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:11:17 -0800 Subject: [PATCH 34/72] 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 2a494dec50677c4ec44c249ab1e68d9b22ab4771 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:54:27 -0800 Subject: [PATCH 35/72] Cutting Tools: Stencil Bridges keeps the islands of a stencil attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Cutting Tools › Stencil Bridges… (`addon.cutting.bridges`) cuts strips of a set width out of the selected art so that a stencil cut from it holds together: every island (the inside of an O, A or 8, a hole in a shape) stays joined to the sheet, directly or through another island. Bridges take the shortest crossings that fit first, then spread around each island up to the count asked for, at least two widths apart. They run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. - The art is measured by the area its outlines enclose, whatever their paint, with hidden objects left out; type by its glyph outlines, without converting the type. The result is one compound path with the strips cut out, painted as the topmost selected shape with outlines, in the place of the topmost selected object with outlines. Selected objects with no outlines in the art stay where they are. - Keep Original (on by default) moves the selected objects that make up the art to a new hidden layer, Stencil Original; off replaces them. - Art with no islands, or none a bridge of the width fits, is left as it is with a message and no undo step. Islands the cut result still encloses are counted in `unbridged`, with a message. Otherwise the run is one undo step. - Curves are followed within a twentieth of the width (0.002 to 0.25 pt). Art that reaches more than 10000000 pt from the origin or has more than 200000 anchors is refused before it is combined. Art is also refused when its combined area has more than 4000 outlines, when its outlines need more than 200000 straight segments, or when placing the outlines or joining every island takes more work than a fixed budget allows; when the budget runs out while extra bridges are added, the bridges found so far are kept. Tests in vectorcraft-pathops, vectorcraft-engine and vectorcraft-ui-egui, command_sweep cases, catalog rows in all 27 languages, docs/mcp.md, the evidence list in docs/target-app-parity.md and the ROADMAP progress log. --- ROADMAP.md | 1 + crates/engine/src/cmd/addons.rs | 2 + crates/engine/src/cmd/addons/cutting.rs | 11 + .../engine/src/cmd/addons/cutting/stencil.rs | 167 +++++ .../src/cmd/addons/cutting/stencil/tests.rs | 328 +++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/tests/command_sweep.rs | 6 + crates/pathops/src/addons.rs | 3 + crates/pathops/src/addons/stencil.rs | 653 ++++++++++++++++++ crates/pathops/src/addons/stencil/tests.rs | 326 +++++++++ crates/ui-egui/src/addons.rs | 2 + crates/ui-egui/src/addons/cutting.rs | 20 + crates/ui-egui/src/addons/cutting/tests.rs | 54 ++ crates/ui-egui/src/dialogs/command.rs | 3 + crates/ui-egui/src/i18n/bg.tsv | 7 + crates/ui-egui/src/i18n/bs.tsv | 7 + crates/ui-egui/src/i18n/ca.tsv | 12 + crates/ui-egui/src/i18n/cs.tsv | 12 + crates/ui-egui/src/i18n/da.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 12 + crates/ui-egui/src/i18n/el.tsv | 12 + crates/ui-egui/src/i18n/es.tsv | 12 + crates/ui-egui/src/i18n/fi.tsv | 12 + crates/ui-egui/src/i18n/fr.tsv | 12 + crates/ui-egui/src/i18n/hi.tsv | 12 + crates/ui-egui/src/i18n/hu.tsv | 12 + crates/ui-egui/src/i18n/id.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 12 + crates/ui-egui/src/i18n/ja.tsv | 12 + crates/ui-egui/src/i18n/ka.tsv | 12 + crates/ui-egui/src/i18n/ko.tsv | 12 + crates/ui-egui/src/i18n/nl.tsv | 7 + crates/ui-egui/src/i18n/pl.tsv | 12 + crates/ui-egui/src/i18n/pt-br.tsv | 12 + crates/ui-egui/src/i18n/ru.tsv | 12 + crates/ui-egui/src/i18n/sv.tsv | 12 + crates/ui-egui/src/i18n/tr.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/i18n/vi.tsv | 12 + crates/ui-egui/src/i18n/zh-hans.tsv | 12 + crates/ui-egui/src/i18n/zh-hant.tsv | 12 + docs/mcp.md | 54 ++ docs/target-app-parity.md | 4 + 43 files changed, 1934 insertions(+) create mode 100644 crates/engine/src/cmd/addons/cutting.rs create mode 100644 crates/engine/src/cmd/addons/cutting/stencil.rs create mode 100644 crates/engine/src/cmd/addons/cutting/stencil/tests.rs create mode 100644 crates/pathops/src/addons/stencil.rs create mode 100644 crates/pathops/src/addons/stencil/tests.rs create mode 100644 crates/ui-egui/src/addons/cutting.rs create mode 100644 crates/ui-egui/src/addons/cutting/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 327036c371..a9187e8a25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. +- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 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. diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 4c4671f6b0..0815404835 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -5,6 +5,7 @@ mod arrange; mod cleanup; mod color; mod contour; +mod cutting; mod document; mod image; mod pathtools; @@ -23,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> { v.extend(cleanup::specs()); v.extend(color::specs()); v.extend(contour::specs()); + v.extend(cutting::specs()); v.extend(document::specs()); v.extend(image::specs()); v.extend(pathtools::specs()); diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs new file mode 100644 index 0000000000..8412af9a68 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -0,0 +1,11 @@ +//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil +//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together +//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's. + +mod stencil; + +use super::super::CommandSpec; + +pub fn specs() -> Vec<CommandSpec> { + stencil::specs() +} diff --git a/crates/engine/src/cmd/addons/cutting/stencil.rs b/crates/engine/src/cmd/addons/cutting/stencil.rs new file mode 100644 index 0000000000..a8b4cfe591 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/stencil.rs @@ -0,0 +1,167 @@ +//! Stencil Bridges (`addon.cutting.bridges`), a command of the built-in Cutting Tools add-on: it +//! cuts bridges out of the selected art so that a stencil cut from it holds together. Each island +//! of the stencil sheet (the inside of an O, a hole in a shape) stays joined to the sheet by strips +//! of a set width across the art (<https://en.wikipedia.org/wiki/Stencil> describes islands and +//! bridges). The art becomes one compound path with the gaps cut, and type is measured by its +//! glyph outlines. The original goes to a new hidden layer or is replaced. The geometry is +//! `vectorcraft_pathops::addons::stencil`. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_pathops::addons::stencil::{BRIDGE_WIDTH_RANGE, BridgeDirection, BridgeSpec, MAX_BRIDGES_PER_ISLAND, stencil_bridges}; + +use super::super::super::edit::selected_roots; +use super::super::super::object::choice; +use super::super::super::pathops::{length_given, node_path, pf_leaves, shape_node, style_of}; +use super::super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// Stencil Bridges' command (the id in [`specs`]). +pub const BRIDGES: &str = "addon.cutting.bridges"; +/// The ways bridges run, as `direction` takes them. +pub const DIRECTIONS: [&str; 3] = ["vertical", "horizontal", "shortest"]; +/// A bridge's width when none is given, in points. +pub const DEFAULT_WIDTH: f64 = 2.0; +/// The name of the hidden layer the original art goes to (numbered when it is taken). +const ORIGINAL_LAYER: &str = "Stencil Original"; + +pub fn specs() -> Vec<CommandSpec> { + vec![cmd!( + "addon.cutting.bridges", + "Stencil Bridges…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{width?: pt 0.01..1000 (2), count?: whole number 1..16 of bridges each island gets (1; fewer where it has no room and more where other islands are joined to the sheet through it), direction?: \"vertical\" | \"horizontal\" | \"shortest\" (the shortest crossing among 12 directions 15° apart) (\"vertical\"), keepOriginal?: true (the selected objects that make up the art go to a new hidden layer named \"Stencil Original\"; false: they are replaced)} cut bridges out of the selected art so that a stencil cut from it holds together: every island (an area the art encloses, such as the inside of an O or a hole in a shape) is joined to the area around the art by strips `width` wide across the art, the shortest crossings first, then spread around the island. The art is measured by the area its outlines enclose, whatever their paint (open paths closed as a fill closes them; type by its glyph outlines; groups by their members; hidden objects left out) and becomes one compound path with the gaps cut, painted as the topmost selected shape, in the place of the topmost selected object that has outlines. Selected objects with no outlines in the art stay where they are. Art with no islands, or none a bridge of the width fits, is left as it is, with a warning and no undo step; islands the cut result still encloses are counted in `unbridged`, with a warning. Art that reaches more than 10000000 pt from the origin, has more than 200000 anchors, whose combined area has more than 4000 outlines, whose outlines need more than 200000 straight segments, or that takes more work to measure than a fixed budget allows is refused. One undo step; the new path is selected → {id, islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}", + has_selection, + bridges + )] +} + +fn bridges(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = BRIDGES; + let width = length_given(p, "width", C)?.unwrap_or(DEFAULT_WIDTH); + let (lo, hi) = BRIDGE_WIDTH_RANGE; + if !(lo..=hi).contains(&width) { + return Err(bad(C, format!("`width` must be from {lo} to {hi} pt"))); + } + let count = number_or(p, "count", 1.0, C)?; + if count.fract() != 0.0 || !(1.0..=MAX_BRIDGES_PER_ISLAND as f64).contains(&count) { + return Err(bad(C, format!("`count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND}"))); + } + let direction = match choice(p, C, "direction", &DIRECTIONS)?.unwrap_or("vertical") { + "horizontal" => BridgeDirection::Horizontal, + "shortest" => BridgeDirection::Shortest, + _ => BridgeDirection::Vertical, + }; + let keep = switch_or(p, "keepOriginal", true, C)?; + let selected = selected_roots(s)?; + let (bridged, style, roots) = { + let d = &s.doc()?.doc; + // The art's outlines, the selected objects they come from, and the topmost object that + // has some, whose paint the result takes. + let mut parts: Vec<(PathData, FillRule)> = vec![]; + let mut roots = vec![]; + let mut top = None; + for id in selected { + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + let mut leaves = vec![]; + shown_leaves(d, n, &mut leaves); + let before = parts.len(); + for leaf in leaves { + if let Some(part) = node_path(&leaf).filter(|(p, _)| !p.is_empty()) { + parts.push(part); + top = Some(leaf); + } + } + if parts.len() > before { + roots.push(id); + } + } + let Some(top) = top else { + let label = "Stencil Bridges"; + return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text"))); + }; + let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); + let spec = BridgeSpec { width, count: count as usize, direction }; + let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?; + (bridged, style_of(&top), roots) + }; + let summary = |id: Option<NodeId>, original: Option<NodeId>| { + json!({ + "id": id.map(|i| i.0), "islands": bridged.islands, "bridges": bridged.bridges, "unbridged": bridged.unbridged, + "original": original.map(|i| i.0), + }) + }; + if bridged.islands == 0 { + let mut r = summary(None, None); + // The warnings are written as `EngineError::Other` so that the i18n message scan + // (`untranslated_report`) finds them. + r["warning"] = json!(EngineError::Other("The selected art has no islands to bridge".into()).to_string()); + return Ok(r); + } + if bridged.bridges == 0 { + let mut r = summary(None, None); + r["warning"] = json!(EngineError::Other("No bridge of this width fits across the selected art".into()).to_string()); + return Ok(r); + } + let path = bridged.path.clone(); + let (id, original) = s.edit("Stencil Bridges", |d, sel| { + let mut made = shape_node(d, path, None); + style.apply(&mut made); + let mut taken = vec![]; + let id = d.wrap(&roots, |nodes| { + taken = nodes; + made + })?; + let original = if keep { Some(hide_originals(d, taken)?) } else { None }; + sel.set([id]); + Ok((id, original)) + })?; + let mut r = summary(Some(id), original); + if bridged.unbridged > 0 { + r["warning"] = json!(EngineError::Other("Some islands could not be joined to the area around the art".into()).to_string()); + } + Ok(r) +} + +/// The leaf shapes of `n` as [`pf_leaves`] gives them, leaving out hidden objects and the members +/// of hidden groups. +fn shown_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) { + if !n.visible { + return; + } + match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => { + for c in children { + shown_leaves(d, c, out); + } + } + _ => pf_leaves(d, n, out), + } +} + +/// Put `nodes` in a new hidden layer above the others, in their order, and return its id. +fn hide_originals(d: &mut Document, nodes: Vec<Arc<Node>>) -> Result<NodeId> { + let mut taken = std::collections::HashSet::new(); + d.walk(|n| { + if n.is_layer() { + taken.extend(n.name.clone()); + } + }); + let name = std::iter::once(ORIGINAL_LAYER.to_string()) + .chain((2..).map(|i| format!("{ORIGINAL_LAYER} {i}"))) + .find(|name| !taken.contains(name)) + .unwrap_or_else(|| ORIGINAL_LAYER.to_string()); + let layer = d.add_layer(Some(&name)); + for (i, n) in nodes.into_iter().enumerate() { + d.insert(Some(layer), i, Arc::unwrap_or_clone(n))?; + } + d.node_mut(layer).ok_or(EngineError::NoNode(layer))?.visible = false; + Ok(layer) +} diff --git a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs new file mode 100644 index 0000000000..ad072ca13d --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs @@ -0,0 +1,328 @@ +//! Tests for `addon.cutting.bridges`: bridges cut out of the selected art keep a stencil's islands +//! attached. + +use kurbo::Shape; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; + +use super::super::super::super::pathops::node_path; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); +} + +/// `outer` less each of `holes` (x, y, width, height), as one compound path, selected. +fn shape_with_holes(s: &mut Session, outer: (f64, f64, f64, f64), holes: &[(f64, f64, f64, f64)]) -> NodeId { + let mut ids = vec![rect(s, outer.0, outer.1, outer.2, outer.3)]; + for h in holes { + ids.push(rect(s, h.0, h.1, h.2, h.3)); + } + select(s, &ids); + let r = s.execute("object.pathfinder.minusFront", &json!({})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + select(s, &[id]); + id +} + +/// A square O: 100 pt across at (100, 100), with bars 20 pt thick. +fn letter_o(s: &mut Session) -> NodeId { + shape_with_holes(s, (100.0, 100.0, 100.0, 100.0), &[(120.0, 120.0, 60.0, 60.0)]) +} + +fn id_of(r: &Value) -> NodeId { + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Is `(x, y)` inside the area object `id` fills? +fn fills(s: &Session, id: NodeId, x: f64, y: f64) -> bool { + let (path, rule) = node_path(&node(s, id)).unwrap(); + let w = path.to_bezpath().winding(kurbo::Point::new(x, y)); + match rule { + vectorcraft_geom::FillRule::EvenOdd => w % 2 != 0, + vectorcraft_geom::FillRule::NonZero => w != 0, + } +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn an_o_gets_a_bridge_and_the_original_goes_to_a_hidden_layer() { + let mut s = session(); + let o = letter_o(&mut s); + let fill = node(&s, o).appearance.fill_paint(); + let steps = undo_steps(&s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0))); + assert!(r.get("warning").is_none(), "{r}"); + let made = id_of(&r); + assert_eq!(s.doc().unwrap().selection.objects, vec![made]); + assert_eq!(undo_steps(&s), steps + 1, "one undo step"); + // The bridge crosses the top bar in the middle; the rest of the O stays, painted as it was. + assert!(!fills(&s, made, 150.0, 110.0)); + assert!(fills(&s, made, 130.0, 110.0) && fills(&s, made, 150.0, 190.0) && fills(&s, made, 110.0, 150.0)); + assert_eq!(node(&s, made).appearance.fill_paint(), fill); + // The original, unchanged, is on a new hidden layer. + let layer = NodeId(r["original"].as_u64().unwrap()); + let l = node(&s, layer); + assert_eq!((l.name.as_deref(), l.visible), (Some("Stencil Original"), false)); + let held = s.doc().unwrap().doc.children(Some(layer)).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>()); + assert_eq!(held, Some(vec![o])); + // The island is joined to the sheet: the result has none left. + let again = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!(again["islands"], json!(0)); + assert!(again["warning"].as_str().is_some_and(|w| w.contains("no islands")), "{again}"); + assert_eq!(undo_steps(&s), steps + 1, "no islands: no undo step"); + // Undo puts the O back where it was and takes the layer away. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(layer).is_none() && d.node(made).is_none() && d.node(o).is_some_and(|n| n.visible)); +} + +#[test] +fn keep_original_off_replaces_the_art() { + let mut s = session(); + let o = letter_o(&mut s); + let layers = s.doc().unwrap().doc.layers.len(); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap(); + assert_eq!(r["original"], Value::Null); + let d = &s.doc().unwrap().doc; + assert!(d.node(o).is_none() && d.node(id_of(&r)).is_some()); + assert_eq!(d.layers.len(), layers); +} + +#[test] +fn the_bridge_is_as_wide_as_asked() { + for width in [2.0, 8.0] { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": width})).unwrap(); + let made = id_of(&r); + let half = width / 2.0; + assert!(!fills(&s, made, 150.0 - half * 0.9, 110.0) && !fills(&s, made, 150.0 + half * 0.9, 110.0), "{width}"); + assert!(fills(&s, made, 150.0 - half * 1.2, 110.0) && fills(&s, made, 150.0 + half * 1.2, 110.0), "{width}"); + } +} + +#[test] +fn the_width_takes_units() { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": "2 mm"})).unwrap(); + let made = id_of(&r); + // 2 mm is about 5.67 pt. + assert!(!fills(&s, made, 152.5, 110.0) && fills(&s, made, 154.0, 110.0)); +} + +#[test] +fn the_shortest_crossing_takes_the_thinnest_bar() { + let mut s = session(); + // The left bar is 5 pt thick, the others 20. + shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(105.0, 120.0, 75.0, 60.0)]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "shortest"})).unwrap(); + let made = id_of(&r); + assert!(!fills(&s, made, 102.5, 150.0), "the bridge crosses the thin bar"); + assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 190.0, 150.0)); +} + +#[test] +fn horizontal_bridges_cross_the_sides() { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "horizontal"})).unwrap(); + let made = id_of(&r); + assert!(!fills(&s, made, 110.0, 150.0) || !fills(&s, made, 190.0, 150.0)); + assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 150.0, 190.0)); +} + +#[test] +fn a_shape_with_two_holes_gets_a_bridge_for_each() { + let mut s = session(); + shape_with_holes(&mut s, (100.0, 100.0, 100.0, 50.0), &[(110.0, 110.0, 30.0, 30.0), (160.0, 110.0, 30.0, 30.0)]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 3, "count": 2})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(4), json!(0))); + let made = id_of(&r); + for x in [125.0, 175.0] { + assert!(!fills(&s, made, x, 105.0) && !fills(&s, made, x, 145.0), "{x}: a bridge above and below each hole"); + } +} + +#[test] +fn type_is_bridged_by_its_glyph_outlines_and_stays_type() { + let mut s = session(); + let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "O", "size": 144})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[t]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 3})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)), "{r}"); + assert!(matches!(node(&s, id_of(&r)).kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); + // The type itself is not converted: it is on the hidden layer, still type. + assert!(matches!(node(&s, t).kind, NodeKind::Text(_))); + assert!(!node(&s, NodeId(r["original"].as_u64().unwrap())).visible); +} + +#[test] +fn the_result_is_painted_as_the_topmost_object_with_outlines() { + let mut s = session(); + let o = letter_o(&mut s); + // Type holding only a space draws no outlines, so it does not set the paint, though it is on top. + let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[o]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let red = node(&s, o).appearance.fill_paint(); + select(&mut s, &[o, t]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!(node(&s, id_of(&r)).appearance.fill_paint(), red); +} + +#[test] +fn hidden_members_of_a_group_are_left_out() { + let mut s = session(); + let o = letter_o(&mut s); + // A hidden square that would fill the O's hole, grouped with it. + let cover = rect(&mut s, 120.0, 120.0, 60.0, 60.0); + select(&mut s, &[o, cover]); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[cover]); + s.execute("object.hide", &json!({})).unwrap(); + select(&mut s, &[g]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone()), (json!(1), json!(1)), "{r}"); +} + +#[test] +fn art_without_islands_changes_nothing() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + select(&mut s, &[a]); + let (steps, count) = (undo_steps(&s), s.doc().unwrap().doc.node_count()); + let r = s.execute("addon.cutting.bridges", &json!({})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["id"].clone()), (json!(0), json!(0), Value::Null)); + assert!(r["warning"].as_str().is_some(), "{r}"); + assert_eq!((undo_steps(&s), s.doc().unwrap().doc.node_count()), (steps, count)); +} + +#[test] +fn an_island_too_narrow_for_the_width_is_reported() { + let mut s = session(); + shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(149.0, 120.0, 2.0, 60.0)]); + let steps = undo_steps(&s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(0), json!(1))); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("No bridge")), "{r}"); + assert_eq!(undo_steps(&s), steps); +} + +#[test] +fn islands_left_without_a_bridge_are_reported_beside_the_ones_bridged() { + let mut s = session(); + // A wide hole on the left and a 2 pt slot on the right. + shape_with_holes(&mut s, (100.0, 100.0, 200.0, 100.0), &[(120.0, 120.0, 60.0, 60.0), (249.0, 120.0, 2.0, 60.0)]); + let steps = undo_steps(&s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(1), json!(1))); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("could not be joined")), "{r}"); + assert_eq!(undo_steps(&s), steps + 1); + assert!(!fills(&s, id_of(&r), 150.0, 110.0), "the wide hole is bridged"); +} + +#[test] +fn each_run_keeps_its_original_on_a_layer_of_its_own() { + let mut s = session(); + letter_o(&mut s); + let first = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + shape_with_holes(&mut s, (300.0, 100.0, 100.0, 100.0), &[(320.0, 120.0, 60.0, 60.0)]); + let second = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + let name = |r: &Value| node(&s, NodeId(r["original"].as_u64().unwrap())).name; + assert_eq!((name(&first).as_deref(), name(&second).as_deref()), (Some("Stencil Original"), Some("Stencil Original 2"))); +} + +#[test] +fn bad_params_are_refused() { + let mut s = session(); + letter_o(&mut s); + let steps = undo_steps(&s); + for (key, p) in [ + ("width", json!({"width": 0})), + ("width", json!({"width": 5000})), + ("width", json!({"width": "wide"})), + ("count", json!({"count": 0})), + ("count", json!({"count": 1.5})), + ("count", json!({"count": 17})), + ("count", json!({"count": "two"})), + ("direction", json!({"direction": "diagonal"})), + ("direction", json!({"direction": 90})), + ("keepOriginal", json!({"keepOriginal": "yes"})), + ] { + let e = s.execute("addon.cutting.bridges", &p).unwrap_err().to_string(); + assert!(e.contains(key), "{p}: {e}"); + } + assert_eq!(undo_steps(&s), steps); + assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run"); +} + +#[test] +fn selected_objects_without_outlines_stay_where_they_are() { + for keep in [true, false] { + let mut s = session(); + let o = letter_o(&mut s); + // Type holding only a space has no outlines. + let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap()); + let layer = |s: &Session, id: NodeId| s.doc().unwrap().doc.layer_of(id); + let before = layer(&s, t); + select(&mut s, &[o, t]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": keep})).unwrap(); + assert_eq!(r["bridges"], json!(1), "{r}"); + assert!(node(&s, t).visible && layer(&s, t) == before, "keep {keep}: the type stays where it was"); + if keep { + let held = + s.doc().unwrap().doc.children(Some(NodeId(r["original"].as_u64().unwrap()))).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>()); + assert_eq!(held, Some(vec![o])); + } + } +} + +#[test] +fn the_result_takes_the_place_of_the_topmost_selected_object() { + let mut s = session(); + let o = letter_o(&mut s); + let between = rect(&mut s, 400.0, 100.0, 50.0, 50.0); + let o2 = shape_with_holes(&mut s, (500.0, 100.0, 100.0, 100.0), &[(520.0, 120.0, 60.0, 60.0)]); + let above = rect(&mut s, 400.0, 300.0, 50.0, 50.0); + select(&mut s, &[o, o2]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap(); + let made = id_of(&r); + let d = &s.doc().unwrap().doc; + let order: Vec<NodeId> = d.children(d.layer_of(made)).unwrap().iter().map(|n| n.id).collect(); + let at = |id: NodeId| order.iter().position(|i| *i == id).unwrap(); + assert!(at(between) < at(made) && at(made) < at(above), "{order:?}"); +} + +#[test] +fn the_result_is_one_path_when_one_outline_is_left() { + for (count, plain) in [(1, true), (2, false)] { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "count": count})).unwrap(); + let kind = node(&s, id_of(&r)).kind; + // One bridge leaves a C, one outline; two leave two pieces. + assert_eq!(matches!(kind, NodeKind::Path { .. }), plain, "count {count}"); + assert_eq!(matches!(kind, NodeKind::Compound { .. }), !plain, "count {count}"); + } +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 51470e742d..4065cc0a7c 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.cutting.bridges", "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 947cf559d0..28799ae686 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -807,6 +807,12 @@ fn structured_junk() { ("addon.type.releasePath", json!({"mode": "text", "keepOriginal": false, "id": 2})), ("addon.type.releasePath", json!({"mode": ["both"], "keepOriginal": "no", "ids": "all", "id": -1})), ("addon.type.releasePath", json!({"mode": "nope", "keepOriginal": 1e308, "ids": [1.5, null, "x"]})), + // Cutting Tools add-on: Stencil Bridges. + ("addon.cutting.bridges", json!({"width": 0.01, "count": 16, "direction": "shortest", "keepOriginal": true})), + ("addon.cutting.bridges", json!({"width": 1000, "count": 1, "direction": "horizontal", "keepOriginal": false})), + ("addon.cutting.bridges", json!({"width": "3 mm", "direction": "vertical"})), + ("addon.cutting.bridges", json!({"width": f64::MAX, "count": 1e308, "direction": ["vertical"], "keepOriginal": "no"})), + ("addon.cutting.bridges", json!({"width": -1, "count": -3, "direction": "diagonal", "keepOriginal": 1e308})), ("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), ("addon.cleanup.breakText", json!({"by": ["lines"]})), ("addon.cleanup.breakText", json!({"by": "paragraphs"})), diff --git a/crates/pathops/src/addons.rs b/crates/pathops/src/addons.rs index 3896d02d05..436ba984bd 100644 --- a/crates/pathops/src/addons.rs +++ b/crates/pathops/src/addons.rs @@ -10,12 +10,15 @@ //! - [`segment_reshape`]: bending a segment by dragging a point on it (shared by the tools that //! reshape segments directly). //! - [`pen`]: the Smart Pen's straight and curved segments and its point edits. +//! - [`stencil`]: Stencil Bridges, the strips cut out of art so that a stencil cut from it keeps +//! its islands attached. pub mod cleanup; pub mod keep_shape; pub mod pathtools; pub mod pen; pub mod segment_reshape; +pub mod stencil; pub use cleanup::{ Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles, diff --git a/crates/pathops/src/addons/stencil.rs b/crates/pathops/src/addons/stencil.rs new file mode 100644 index 0000000000..437433e40c --- /dev/null +++ b/crates/pathops/src/addons/stencil.rs @@ -0,0 +1,653 @@ +//! Stencil Bridges: the art a stencil is cut from, with strips cut out of it so that every island +//! of the stencil sheet stays attached. +//! +//! The art is the area the stencil's holes take. Each bounded area outside the art (the inside of +//! an O, any hole of a shape) is an island of the sheet. A bridge is a straight strip of a set +//! width cut out of the art, from an island across the art to the sheet beyond it, or to another +//! island. The bridges are picked as a spanning tree over the islands and the sheet (shortest +//! crossings first), so every island that has room for one is joined to the sheet, then each +//! island gets more bridges, spread around it, up to the number asked for. +//! +//! Crossings are measured along straight lines in one direction at a time (vertical, horizontal, +//! or every 15 degrees for the shortest crossing), spaced half a bridge width apart. + +#[cfg(test)] +mod tests; + +use std::cell::Cell; + +use kurbo::{BezPath, ParamCurve, PathEl, Point}; +use vectorcraft_geom::{FillRule, PathData, SubPath}; + +use crate::{BoolOp, DEFAULT_PRECISION, PathOpsError, try_boolean, try_unite_all}; + +/// The narrowest and widest bridge, in points. +pub const BRIDGE_WIDTH_RANGE: (f64, f64) = (0.01, 1000.0); +/// The most bridges one island is given. +pub const MAX_BRIDGES_PER_ISLAND: usize = 16; +/// The most straight segments the art is followed by. +pub const MAX_STENCIL_SEGMENTS: usize = 200_000; +/// The most outlines the art may have. +pub const MAX_STENCIL_CONTOURS: usize = 4000; +/// How far from the origin the art and its handles may reach, in points: beyond the coordinates a +/// document holds. +pub const MAX_STENCIL_EXTENT: f64 = 1.0e7; +/// The most segments all lines across the art may look at together. +const MAX_WORK: usize = 300_000_000; +/// The most edges the outlines are tested against when finding which ones bound islands. +const MAX_NESTING_WORK: usize = 50_000_000; +/// The most bands one direction's segments are listed in. +const MAX_LINES: f64 = 1000.0; +/// The most lines all islands are looked across along, in every direction together. +const MAX_ISLAND_LINES: usize = 400_000; +/// The most lines on each side of an island's middle, in one direction. +const MAX_LINES_PER_SIDE: usize = 64; +/// The most times segments are listed against the lines they reach, in one direction. +const MAX_BIN_ENTRIES: usize = 20_000_000; +/// The lines across one bridge its width is checked along. +const CHECKS: usize = 5; +/// The directions the shortest crossing is looked for in, in degrees. +const SHORTEST_STEP_DEG: usize = 15; + +/// Which way bridges run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BridgeDirection { + Vertical, + Horizontal, + /// The shortest crossing in any of 12 directions, 15 degrees apart. + Shortest, +} + +/// What [`stencil_bridges`] makes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BridgeSpec { + /// Each bridge's width (pt), in [`BRIDGE_WIDTH_RANGE`]. + pub width: f64, + /// The bridges each island is given, 1 to [`MAX_BRIDGES_PER_ISLAND`] (fewer where it has no + /// room for more, and more where other islands are joined to the sheet through it). + pub count: usize, + pub direction: BridgeDirection, +} + +/// The art with its bridges cut out. +#[derive(Clone, Debug, PartialEq)] +pub struct Bridged { + /// The art as one area, less the bridges. + pub path: PathData, + /// The islands the art held. + pub islands: usize, + /// The bridges cut. + pub bridges: usize, + /// The islands the cut result still encloses: no bridge of the width fits, or the bridges that + /// fit join them only to each other. + pub unbridged: usize, +} + +/// `art` (each path with its fill rule; open subpaths are closed as a fill closes them) as one +/// area with bridges cut out of it as `spec` says. With no islands, the area is returned as it is +/// and no bridge is cut. +pub fn stencil_bridges(art: &[(&PathData, FillRule)], spec: BridgeSpec) -> Result<Bridged, PathOpsError> { + let (lo, hi) = BRIDGE_WIDTH_RANGE; + if !(spec.width.is_finite() && (lo..=hi).contains(&spec.width)) || !(1..=MAX_BRIDGES_PER_ISLAND).contains(&spec.count) { + return Err(PathOpsError::Degenerate); + } + if too_large(art) { + return Err(PathOpsError::TooComplex); + } + let closed: Vec<PathData> = + art.iter().map(|(p, _)| PathData::new(p.subpaths.iter().map(|sp| SubPath { closed: true, ..sp.clone() }).collect())).collect(); + let refs: Vec<(&PathData, FillRule)> = closed.iter().zip(art).map(|(p, (_, r))| (p, *r)).collect(); + bridge_region(try_unite_all(&refs)?, spec) +} + +/// Whether `art` has more than [`MAX_STENCIL_SEGMENTS`] anchors or reaches past +/// [`MAX_STENCIL_EXTENT`]: art refused before it is united. +fn too_large(art: &[(&PathData, FillRule)]) -> bool { + let anchors: usize = art.iter().flat_map(|(p, _)| &p.subpaths).map(|sp| sp.anchors.len()).sum(); + anchors > MAX_STENCIL_SEGMENTS || !art.iter().all(|(p, _)| within_extent(&p.to_bezpath())) +} + +/// [`stencil_bridges`] on `region`, an area with no overlaps. +fn bridge_region(region: PathData, spec: BridgeSpec) -> Result<Bridged, PathOpsError> { + let contours = contours(®ion, spec.width)?; + let holes = contours.iter().filter(|c| c.hole).count(); + if holes == 0 { + return Ok(Bridged { path: region, islands: 0, bridges: 0, unbridged: 0 }); + } + let (chosen, unjoined) = choose(&contours, spec, MAX_WORK)?; + if chosen.is_empty() { + return Ok(Bridged { path: region, islands: holes, bridges: 0, unbridged: unjoined }); + } + let strips = PathData::new(chosen.iter().map(|c| SubPath::polyline(&c.strip, true)).collect()); + let path = try_boolean(®ion, FillRule::NonZero, &strips, FillRule::NonZero, BoolOp::Difference, DEFAULT_PRECISION)?; + // The islands the result still encloses, counted on the cut outlines themselves (removing art + // never encloses a new one). + let unbridged = match self::contours(&path, spec.width) { + Ok(left) => left.iter().filter(|c| c.hole).count(), + Err(_) => unjoined, + }; + Ok(Bridged { path, islands: holes, bridges: chosen.len(), unbridged }) +} + +/// The bridges `spec` asks for across the art that `contours` outline, and how many islands they +/// leave apart from the sheet. Lines across the art look at no more than `work` segments in all; +/// past that, the art is refused when the islands are not all joined yet, and otherwise the extra +/// bridges found so far are kept. +fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec<Bridge>, usize), PathOpsError> { + let degrees: Vec<f64> = match spec.direction { + BridgeDirection::Vertical => vec![90.0], + BridgeDirection::Horizontal => vec![0.0], + BridgeDirection::Shortest => (0..180).step_by(SHORTEST_STEP_DEG).map(|d| d as f64).collect(), + }; + let budget = work / degrees.len().max(1); + let mut views = vec![]; + for deg in degrees { + views.push(View::new(contours, deg.to_radians(), spec.width, budget)?); + } + let mut picker = Picker::new(contours, &views, spec.width); + picker.connect(); + if views.iter().any(View::exhausted) { + return Err(PathOpsError::TooComplex); + } + picker.spread(spec.count); + let unjoined = picker.unjoined(); + Ok((picker.chosen, unjoined)) +} + +/// One closed outline of the art, followed in straight segments. +struct Contour { + points: Vec<Point>, + /// Whether it bounds an island (it lies inside an odd number of the others). + hole: bool, + /// Its area, without sign. + area: f64, + /// Its box: (min x, min y, max x, max y). + bounds: (f64, f64, f64, f64), +} + +/// The outlines of `region` (an area with no overlaps) followed within a tolerance set by the +/// bridge width: a twentieth of it, from 0.002 to 0.25 pt, raised for a curve that would need more +/// than [`vectorcraft_geom::MAX_FLATTEN_PER_CURVE`] segments. Art reaching past +/// [`MAX_STENCIL_EXTENT`] is refused. +fn contours(region: &PathData, width: f64) -> Result<Vec<Contour>, PathOpsError> { + if !within_extent(®ion.to_bezpath()) { + return Err(PathOpsError::TooComplex); + } + let tolerance = (width / 20.0).clamp(0.002, 0.25); + let mut out: Vec<Contour> = vec![]; + let mut segments = 0usize; + for sp in ®ion.subpaths { + let room = MAX_STENCIL_SEGMENTS.saturating_sub(segments); + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let mut points = follow(&bp, tolerance, room)?; + while points.len() > 1 && points.first() == points.last() { + points.pop(); + } + if points.len() < 3 { + continue; + } + segments += points.len(); + if segments > MAX_STENCIL_SEGMENTS || out.len() >= MAX_STENCIL_CONTOURS { + return Err(PathOpsError::TooComplex); + } + if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite())) { + return Err(PathOpsError::NonFinite); + } + let area = signed_area(&points).abs(); + let bounds = points.iter().fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| { + (b.0.min(p.x), b.1.min(p.y), b.2.max(p.x), b.3.max(p.y)) + }); + out.push(Contour { points, hole: false, area, bounds }); + } + // An outline too small to see is dropped, so its inside counts as art (or, for a speck of art, + // as the area around it). + let tiny = (tolerance * tolerance).max(1e-9); + out.retain(|c| c.area > tiny); + // An outline inside an odd number of others bounds an island. Each outline is placed by the + // middle of its longest edge: outlines of an area with no overlaps meet at most at corners, so + // that point lies on no other outline. + let mut work = 0usize; + let mut depths = Vec::with_capacity(out.len()); + for (i, c) in out.iter().enumerate() { + let p = longest_edge_middle(&c.points); + let mut depth = 0usize; + for (j, o) in out.iter().enumerate() { + if j == i || o.area < c.area || !holds_box(o.bounds, c.bounds) { + continue; + } + work += o.points.len(); + if work > MAX_NESTING_WORK { + return Err(PathOpsError::TooComplex); + } + if contains(o, p) { + depth += 1; + } + } + depths.push(depth); + } + for (c, depth) in out.iter_mut().zip(depths) { + c.hole = depth % 2 == 1; + } + Ok(out) +} + +/// The middle of the longest edge of the closed outline through `points`. +fn longest_edge_middle(points: &[Point]) -> Point { + let n = points.len(); + (0..n) + .map(|i| (points[i], points[(i + 1) % n])) + .max_by(|(a, b), (c, d)| a.distance(*b).total_cmp(&c.distance(*d))) + .map_or(Point::ZERO, |(a, b)| a.midpoint(b)) +} + +/// Whether box `outer` holds box `inner`, edges included. Boxes are (min x, min y, max x, max y). +fn holds_box(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> bool { + outer.0 <= inner.0 && outer.1 <= inner.1 && outer.2 >= inner.2 && outer.3 >= inner.3 +} + +/// Whether `bp` and its handles stay within [`MAX_STENCIL_EXTENT`] of the origin. +fn within_extent(bp: &BezPath) -> bool { + let reach = bp.control_box(); + [reach.x0, reach.y0, reach.x1, reach.y1].iter().all(|v| v.abs() <= MAX_STENCIL_EXTENT) +} + +/// The points that follow outline `bp` within `tolerance`, segment by segment, or +/// [`PathOpsError::TooComplex`] once they pass `room` (and one more for a closed outline's +/// repeated start, which the caller drops). It stops at the first segment past the limit. +fn follow(bp: &BezPath, tolerance: f64, room: usize) -> Result<Vec<Point>, PathOpsError> { + let limit = room.saturating_add(1); + let mut points: Vec<Point> = vec![]; + for seg in bp.segments() { + if points.is_empty() { + points.push(seg.start()); + } + let mut over = false; + vectorcraft_geom::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tolerance, |el| match el { + PathEl::LineTo(p) if points.len() < limit => points.push(p), + PathEl::LineTo(_) => over = true, + _ => {} + }); + if over { + return Err(PathOpsError::TooComplex); + } + } + Ok(points) +} + +fn signed_area(points: &[Point]) -> f64 { + let n = points.len(); + (0..n).map(|i| points[i].to_vec2().cross(points[(i + 1) % n].to_vec2())).sum::<f64>() / 2.0 +} + +/// Whether `p` is inside contour `c` (even-odd). +fn contains(c: &Contour, p: Point) -> bool { + let (x0, y0, x1, y1) = c.bounds; + if p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 { + return false; + } + let n = c.points.len(); + let mut inside = false; + for i in 0..n { + let (a, b) = (c.points[i], c.points[(i + 1) % n]); + if (a.y <= p.y) != (b.y <= p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) { + inside = !inside; + } + } + inside +} + +/// The area outside the art that a point is in: 0 for the sheet around the art, `i + 1` for the +/// island contour `i` bounds. +type Area = usize; +const SHEET: Area = 0; + +/// The art seen along lines in one direction: rotated so the lines run along x, with its segments +/// listed by the bands of y they reach. +struct View { + angle: f64, + /// Rotated segments, each with the contour it belongs to. + segments: Vec<(Point, Point, usize)>, + /// The lowest y of the rotated art. + y0: f64, + /// The band height, and the lines' spacing. + step: f64, + bands: Vec<Vec<u32>>, + /// Each contour's lowest and highest rotated y. + ranges: Vec<(f64, f64)>, + /// The segments lines may still look at; past it, every line is missing. + budget: Cell<usize>, +} + +/// Where a line crosses the art: the edges in order (even-odd pairs are the art), and the area +/// each stretch outside the art is in (one more than there are pairs). +struct Line { + xs: Vec<f64>, + areas: Vec<Area>, +} + +impl View { + fn new(contours: &[Contour], angle: f64, width: f64, budget: usize) -> Result<Self, PathOpsError> { + let (sin, cos) = angle.sin_cos(); + let rotate = |p: Point| Point::new(p.x * cos + p.y * sin, -p.x * sin + p.y * cos); + let mut segments = vec![]; + let mut ranges = vec![]; + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for (k, c) in contours.iter().enumerate() { + let pts: Vec<Point> = c.points.iter().map(|p| rotate(*p)).collect(); + let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))); + ranges.push((lo, hi)); + y0 = y0.min(lo); + y1 = y1.max(hi); + for i in 0..pts.len() { + segments.push((pts[i], pts[(i + 1) % pts.len()], k)); + } + } + let span = (y1 - y0).max(width); + let step = (width / 2.0).max(span / MAX_LINES); + let count = (span / step).ceil() as usize + 1; + let mut bands: Vec<Vec<u32>> = vec![vec![]; count]; + let band = |y: f64| (((y - y0) / step).floor().max(0.0) as usize).min(count - 1); + let mut entries = 0usize; + for (i, (a, b, _)) in segments.iter().enumerate() { + let (from, to) = (band(a.y.min(b.y)), band(a.y.max(b.y))); + entries += to - from + 1; + if entries > MAX_BIN_ENTRIES { + return Err(PathOpsError::TooComplex); + } + for list in &mut bands[from..=to] { + list.push(i as u32); + } + } + Ok(View { angle, segments, y0, step, bands, ranges, budget: Cell::new(budget) }) + } + + /// Whether lines looked at more segments than the budget allows. + fn exhausted(&self) -> bool { + self.budget.get() == 0 + } + + /// The art along the line at rotated y `y`, `None` where its edges don't pair up or the budget + /// is spent. + fn line(&self, y: f64, contours: &[Contour]) -> Option<Line> { + let band = ((y - self.y0) / self.step).floor(); + let list = self.bands.get(if band < 0.0 { usize::MAX } else { band as usize })?; + let left = self.budget.get().checked_sub(list.len()).filter(|left| *left > 0); + self.budget.set(left.unwrap_or(0)); + left?; + let mut hits: Vec<(f64, usize)> = vec![]; + for &i in list { + let (a, b, k) = self.segments[i as usize]; + if (a.y <= y) != (b.y <= y) { + hits.push((a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y), k)); + } + } + if hits.len() % 2 == 1 { + return None; + } + hits.sort_by(|p, q| p.0.total_cmp(&q.0)); + // Walking along the line, each contour crossed an odd number of times so far holds the + // point; the smallest island contour holding a stretch outside the art is its island. + let mut open: Vec<usize> = vec![]; + let mut areas = vec![SHEET]; + for (n, (_, k)) in hits.iter().enumerate() { + match open.iter().position(|o| o == k) { + Some(at) => { + open.swap_remove(at); + } + None => open.push(*k), + } + if n % 2 == 1 { + let island = open.iter().filter(|o| contours[**o].hole).min_by(|a, b| contours[**a].area.total_cmp(&contours[**b].area)); + areas.push(island.map_or(SHEET, |k| k + 1)); + } + } + Some(Line { xs: hits.into_iter().map(|(x, _)| x).collect(), areas }) + } + + /// The point at rotated `(x, y)` in the art's own coordinates. + fn unrotate(&self, x: f64, y: f64) -> Point { + let (sin, cos) = self.angle.sin_cos(); + Point::new(x * cos - y * sin, x * sin + y * cos) + } +} + +/// A crossing of the art along one line, between the areas on either side of it. +#[derive(Clone, Copy, Debug)] +struct Crossing { + view: usize, + y: f64, + /// Where it enters and leaves the art. + from: f64, + to: f64, + areas: (Area, Area), + /// How far its line is from the middle of the island it starts from. + off_middle: f64, +} + +impl Crossing { + fn len(&self) -> f64 { + self.to - self.from + } +} + +/// A bridge that fits: its crossing, its middle and its strip's corners. +struct Bridge { + areas: (Area, Area), + middle: Point, + strip: Vec<Point>, +} + +struct Picker<'a> { + contours: &'a [Contour], + views: &'a [View], + width: f64, + crossings: Vec<Crossing>, + /// The crossings that touch each area. + touching: Vec<Vec<usize>>, + parent: Vec<usize>, + chosen: Vec<Bridge>, +} + +impl<'a> Picker<'a> { + fn new(contours: &'a [Contour], views: &'a [View], width: f64) -> Self { + let areas = contours.len() + 1; + let holes = contours.iter().filter(|c| c.hole).count().max(1); + // Each island is looked across along lines through its middle and out from it on both + // sides, half a width apart, or fewer where there are many islands. + let each_side = (MAX_ISLAND_LINES / (holes * views.len()).max(1)).clamp(1, MAX_LINES_PER_SIDE) as f64; + let mut crossings = vec![]; + for (v, view) in views.iter().enumerate() { + for (k, _) in contours.iter().enumerate().filter(|(_, c)| c.hole) { + let island = k + 1; + let (lo, hi) = view.ranges[k]; + let (middle, reach) = ((lo + hi) / 2.0, (hi - lo) / 2.0); + let step = (width / 2.0).max(reach / each_side); + let mut ys = vec![middle]; + let mut d = step; + while d < reach { + ys.extend([middle - d, middle + d]); + d += step; + } + for y in ys { + let Some(line) = view.line(y, contours) else { continue }; + for (j, pair) in line.xs.as_chunks::<2>().0.iter().enumerate() { + let (a, b) = (line.areas[j], line.areas[j + 1]); + if a == b || (a != island && b != island) { + continue; + } + crossings.push(Crossing { view: v, y, from: pair[0], to: pair[1], areas: (a, b), off_middle: (y - middle).abs() }); + } + } + } + } + // Shortest first; among crossings of about the same length, the one nearest the middle of + // its island. + let grain = width * 0.05; + crossings.sort_by(|p, q| (p.len() / grain).round().total_cmp(&(q.len() / grain).round()).then(p.off_middle.total_cmp(&q.off_middle))); + let mut touching = vec![vec![]; areas]; + for (i, c) in crossings.iter().enumerate() { + touching[c.areas.0].push(i); + touching[c.areas.1].push(i); + } + Picker { contours, views, width, crossings, touching, parent: (0..areas).collect(), chosen: vec![] } + } + + fn root(&mut self, mut a: usize) -> usize { + while self.parent[a] != a { + self.parent[a] = self.parent[self.parent[a]]; + a = self.parent[a]; + } + a + } + + /// Join every island to the sheet by the shortest crossings that fit, as a spanning tree. Bridges + /// that only join islands that stay apart from the sheet are not cut. + fn connect(&mut self) { + let mut apart = self.contours.iter().filter(|c| c.hole).count(); + for i in 0..self.crossings.len() { + if apart == 0 { + break; + } + let c = self.crossings[i]; + let (a, b) = (self.root(c.areas.0), self.root(c.areas.1)); + if a == b { + continue; + } + if let Some(bridge) = self.fit(&c) { + self.parent[a] = b; + self.chosen.push(bridge); + apart -= 1; + } + } + let sheet = self.root(SHEET); + for bridge in std::mem::take(&mut self.chosen) { + if self.root(bridge.areas.0) == sheet { + self.chosen.push(bridge); + } + } + } + + /// Give each island more bridges, up to `count`, each far from the island's others (at least + /// two widths away, and as short as can be among those at least 80% as far as the farthest), + /// and no more than twice as long as its shortest crossing that a bridge fits plus a width. + /// A crossing a bridge does not fit is not tried again, and once the lines' budget is spent no + /// more bridges are found. + fn spread(&mut self, count: usize) { + let sheet = self.root(SHEET); + let mut misfit = vec![false; self.crossings.len()]; + for island in 1..self.touching.len() { + if !self.contours[island - 1].hole || self.root(island) != sheet { + continue; + } + let mut limit = None; + loop { + let mine: Vec<Point> = self.chosen.iter().filter(|b| b.areas.0 == island || b.areas.1 == island).map(|b| b.middle).collect(); + if mine.len() >= count { + break; + } + let longest = match limit { + Some(l) => l, + None => { + // The island's crossings are listed shortest first, so the first that fits + // is its shortest bridge. + let mut shortest = None; + for &i in &self.touching[island] { + if misfit[i] { + continue; + } + if self.fit(&self.crossings[i]).is_some() { + shortest = Some(self.crossings[i].len()); + break; + } + misfit[i] = true; + } + let Some(shortest) = shortest else { break }; + *limit.insert(shortest * 2.0 + self.width) + } + }; + let mut ranked: Vec<(f64, usize)> = self.touching[island] + .iter() + .filter(|&&i| !misfit[i] && self.crossings[i].len() <= longest) + .map(|&i| { + let m = self.middle(&self.crossings[i]); + (mine.iter().map(|p| p.distance(m)).fold(f64::INFINITY, f64::min), i) + }) + .filter(|(d, _)| *d >= self.width * 2.0) + .collect(); + // The crossings nearly as far as the farthest go first, in the order `connect` + // takes them (shortest, then nearest the middle); then the rest, farthest first. + let far = ranked.iter().map(|r| r.0).fold(0.0, f64::max) * 0.8; + ranked.sort_by(|p, q| (q.0 >= far).cmp(&(p.0 >= far)).then_with(|| if p.0 >= far { p.1.cmp(&q.1) } else { q.0.total_cmp(&p.0) })); + let mut found = None; + for &(_, i) in &ranked { + match self.fit(&self.crossings[i]) { + Some(bridge) => { + found = Some(bridge); + break; + } + None => misfit[i] = true, + } + } + match found { + Some(bridge) => self.chosen.push(bridge), + None => break, + } + if self.views.iter().any(View::exhausted) { + return; + } + } + } + } + + /// The islands not joined to the sheet. + fn unjoined(&mut self) -> usize { + let sheet = self.root(SHEET); + (1..self.parent.len()).filter(|&a| self.contours[a - 1].hole).filter(|&a| self.root(a) != sheet).count() + } + + fn middle(&self, c: &Crossing) -> Point { + self.views[c.view].unrotate((c.from + c.to) / 2.0, c.y) + } + + /// The bridge of crossing `c`, if a strip of the width fits there: along lines across its + /// width, the art is crossed once between the same two areas, and the strip ends outside the + /// art on each of them. + fn fit(&self, c: &Crossing) -> Option<Bridge> { + let view = &self.views[c.view]; + let half = self.width / 2.0; + let (mut start, mut end) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut room_before, mut room_after) = (f64::NEG_INFINITY, f64::INFINITY); + for k in 0..CHECKS { + let y = c.y - half + self.width * k as f64 / (CHECKS - 1) as f64; + let line = view.line(y, self.contours)?; + let last = line.areas.len() - 1; + // The crossing between the same two areas that overlaps this one, the nearest if several do. + let (j, _) = line + .xs + .as_chunks::<2>() + .0 + .iter() + .enumerate() + .filter(|(j, p)| (line.areas[*j], line.areas[j + 1]) == c.areas && p[1] > c.from - self.width && p[0] < c.to + self.width) + .min_by(|(_, p), (_, q)| (p[0] - c.from).abs().total_cmp(&(q[0] - c.from).abs()))?; + start = start.min(line.xs[2 * j]); + end = end.max(line.xs[2 * j + 1]); + if j > 0 { + room_before = room_before.max(line.xs[2 * j - 1]); + } + if j + 1 < last { + room_after = room_after.min(line.xs[2 * j + 2]); + } + } + if room_before >= start || room_after <= end { + return None; + } + // The strip reaches a little past the art at each end, so no sliver of it is left. + let reach = self.width.min(1.0); + let s0 = (start - reach).max((room_before + start) / 2.0); + let s1 = (end + reach).min((room_after + end) / 2.0); + let strip = [(s0, c.y - half), (s1, c.y - half), (s1, c.y + half), (s0, c.y + half)].map(|(x, y)| view.unrotate(x, y)).to_vec(); + Some(Bridge { areas: c.areas, middle: self.middle(c), strip }) + } +} diff --git a/crates/pathops/src/addons/stencil/tests.rs b/crates/pathops/src/addons/stencil/tests.rs new file mode 100644 index 0000000000..48d2f8148e --- /dev/null +++ b/crates/pathops/src/addons/stencil/tests.rs @@ -0,0 +1,326 @@ +use kurbo::{BezPath, Point, Shape}; +use vectorcraft_geom::{FillRule, PathData, SubPath}; + +use super::*; + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath { + SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) +} + +/// A square ring: `outer` with the hole `inner`, each (x0, y0, x1, y1). +fn ring(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> PathData { + PathData::new(vec![rect(outer.0, outer.1, outer.2, outer.3), rect(inner.0, inner.1, inner.2, inner.3)]) +} + +fn spec(width: f64, count: usize, direction: BridgeDirection) -> BridgeSpec { + BridgeSpec { width, count, direction } +} + +fn run(art: &PathData, spec: BridgeSpec) -> Bridged { + stencil_bridges(&[(art, FillRule::EvenOdd)], spec).unwrap() +} + +/// Is `(x, y)` inside the area `p` draws? +fn inside(p: &PathData, x: f64, y: f64) -> bool { + p.to_bezpath().winding(Point::new(x, y)) != 0 +} + +/// The islands left once the bridges are cut. +fn islands_left(b: &Bridged) -> usize { + run(&b.path, spec(1.0, 1, BridgeDirection::Shortest)).islands +} + +/// A letter O made square: 100 pt across with bars 20 pt thick. +fn letter_o() -> PathData { + ring((0.0, 0.0, 100.0, 100.0), (20.0, 20.0, 80.0, 80.0)) +} + +#[test] +fn an_o_gets_a_bridge_and_its_island_joins_the_sheet() { + let b = run(&letter_o(), spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0)); + assert_eq!(islands_left(&b), 0, "the inside of the O is joined to the sheet"); + // The bridge crosses the top bar at the middle of the hole; the rest of the O stays. + assert!(!inside(&b.path, 50.0, 10.0)); + assert!(inside(&b.path, 30.0, 10.0) && inside(&b.path, 50.0, 90.0) && inside(&b.path, 10.0, 50.0)); +} + +#[test] +fn the_bridge_is_as_wide_as_asked() { + for width in [2.0, 6.0] { + let b = run(&letter_o(), spec(width, 1, BridgeDirection::Vertical)); + let half = width / 2.0; + assert!(!inside(&b.path, 50.0 - half * 0.9, 10.0) && !inside(&b.path, 50.0 + half * 0.9, 10.0), "{width}"); + assert!(inside(&b.path, 50.0 - half * 1.2, 10.0) && inside(&b.path, 50.0 + half * 1.2, 10.0), "{width}"); + } +} + +#[test] +fn horizontal_bridges_cross_the_sides() { + let b = run(&letter_o(), spec(4.0, 1, BridgeDirection::Horizontal)); + assert_eq!((b.bridges, islands_left(&b)), (1, 0)); + assert!(!inside(&b.path, 10.0, 50.0) || !inside(&b.path, 90.0, 50.0)); + assert!(inside(&b.path, 50.0, 10.0) && inside(&b.path, 50.0, 90.0)); +} + +#[test] +fn the_shortest_crossing_takes_the_thinnest_bar() { + // The left bar is 5 pt thick, the others 20. + let art = ring((0.0, 0.0, 100.0, 100.0), (5.0, 20.0, 80.0, 80.0)); + let b = run(&art, spec(4.0, 1, BridgeDirection::Shortest)); + assert_eq!((b.bridges, islands_left(&b)), (1, 0)); + assert!(!inside(&b.path, 2.5, 50.0), "the bridge crosses the thin bar"); + assert!(inside(&b.path, 50.0, 10.0) && inside(&b.path, 90.0, 50.0)); + // Vertical bridges cross a 20 pt bar instead. + let v = run(&art, spec(4.0, 1, BridgeDirection::Vertical)); + assert!(inside(&v.path, 2.5, 50.0) && !inside(&v.path, 42.5, 10.0)); +} + +#[test] +fn a_shape_with_two_holes_gets_a_bridge_for_each() { + let art = PathData::new(vec![rect(0.0, 0.0, 100.0, 50.0), rect(10.0, 10.0, 40.0, 40.0), rect(60.0, 10.0, 90.0, 40.0)]); + let b = run(&art, spec(3.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 0)); + assert_eq!(islands_left(&b), 0); + assert!(!inside(&b.path, 25.0, 5.0) && !inside(&b.path, 75.0, 5.0)); +} + +#[test] +fn more_bridges_per_island_spread_around_it() { + let b = run(&letter_o(), spec(4.0, 2, BridgeDirection::Vertical)); + assert_eq!(b.bridges, 2); + assert!(!inside(&b.path, 50.0, 10.0) && !inside(&b.path, 50.0, 90.0), "one at the top, one at the bottom"); +} + +#[test] +fn a_short_crossing_too_narrow_for_a_bridge_does_not_limit_the_others() { + // Hole A has a 2 pt slot B just above it: the 5 pt crossing from A to B is the shortest, but + // no 4 pt bridge fits there. A's 20 pt crossings to the sheet take its two bridges. + let art = PathData::new(vec![rect(0.0, 0.0, 200.0, 100.0), rect(20.0, 20.0, 80.0, 80.0), rect(49.0, 5.0, 51.0, 15.0)]); + let b = run(&art, spec(4.0, 2, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 1)); +} + +#[test] +fn islands_that_only_reach_each_other_get_no_bridge() { + // Holes Y and X, one above the other, with combs of 2 pt slots above Y and below X: no 4 pt + // vertical bridge reaches the sheet from either, so the bridge that fits between them would + // join nothing to the sheet. + let mut subpaths = vec![rect(0.0, 0.0, 100.0, 100.0), rect(20.0, 20.0, 80.0, 30.0), rect(30.0, 40.0, 70.0, 60.0)]; + for k in 0..27 { + let x = 10.0 + 3.0 * k as f64; + subpaths.push(rect(x, 5.0, x + 2.0, 15.0)); + subpaths.push(rect(x, 85.0, x + 2.0, 95.0)); + } + let b = run(&PathData::new(subpaths), spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (56, 0, 56)); +} + +#[test] +fn an_island_inside_an_island_is_joined_through_it() { + // A ring in the hole of a ring: two islands, the gap between the rings and the inner hole. + let art = + PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), rect(10.0, 10.0, 90.0, 90.0), rect(30.0, 30.0, 70.0, 70.0), rect(40.0, 40.0, 60.0, 60.0)]); + let b = run(&art, spec(2.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 0)); + assert_eq!(islands_left(&b), 0); +} + +#[test] +fn an_island_too_narrow_for_the_width_is_counted() { + let art = ring((0.0, 0.0, 100.0, 100.0), (49.0, 20.0, 51.0, 80.0)); + let b = run(&art, spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 0, 1)); + assert_eq!(islands_left(&b), 1); +} + +#[test] +fn art_without_islands_is_left_whole() { + let art = PathData::new(vec![rect(0.0, 0.0, 50.0, 50.0), rect(100.0, 0.0, 150.0, 50.0)]); + let b = run(&art, spec(4.0, 1, BridgeDirection::Shortest)); + assert_eq!((b.islands, b.bridges, b.unbridged), (0, 0, 0)); + assert!(inside(&b.path, 25.0, 25.0) && inside(&b.path, 125.0, 25.0)); +} + +#[test] +fn the_shortest_crossing_may_run_at_an_angle() { + // A diamond ring: its walls are 20 pt across horizontally and vertically but about 14 pt + // across at 45°, so only a diagonal line finds the shortest crossing. + let diamond = |r: f64| { + let pts = [(100.0, 100.0 - r), (100.0 + r, 100.0), (100.0, 100.0 + r), (100.0 - r, 100.0)].map(|(x, y)| Point::new(x, y)); + SubPath::polyline(&pts, true) + }; + let art = PathData::new(vec![diamond(60.0), diamond(40.0)]); + let b = run(&art, spec(2.0, 1, BridgeDirection::Shortest)); + assert_eq!((b.bridges, islands_left(&b)), (1, 0)); + // The middles of the four diagonal walls: one is cut. The middles of the walls straight + // above, below and beside the hole all stay. + let diagonal = [(125.0, 125.0), (75.0, 75.0), (125.0, 75.0), (75.0, 125.0)]; + assert!(diagonal.iter().any(|&(x, y)| !inside(&b.path, x, y)), "a diagonal bridge"); + for (x, y) in [(150.0, 100.0), (50.0, 100.0), (100.0, 150.0), (100.0, 50.0)] { + assert!(inside(&b.path, x, y), "({x}, {y}) stays"); + } + let h = run(&art, spec(2.0, 1, BridgeDirection::Horizontal)); + assert!(diagonal.iter().all(|&(x, y)| inside(&h.path, x, y))); +} + +#[test] +fn a_hole_touching_another_outline_at_a_corner_is_still_an_island() { + // A diamond hole whose bottom corner, where its outline starts, touches the bottom edge of the + // square around it. + let hole = SubPath::polyline(&[(50.0, 100.0), (70.0, 70.0), (50.0, 40.0), (30.0, 70.0)].map(|(x, y)| Point::new(x, y)), true); + let region = PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), hole]); + let found = contours(®ion, 1.0).unwrap(); + assert_eq!(found.iter().filter(|c| c.hole).count(), 1); +} + +#[test] +fn a_sliver_too_small_to_see_counts_as_art() { + // A hole 10 pt long and 0.005 pt high between the island and the top edge: a bridge that + // reaches only the sliver would join nothing to the sheet. + let region = PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), rect(40.0, 40.0, 60.0, 60.0), rect(45.0, 20.0, 55.0, 20.005)]); + let b = bridge_region(region, spec(5.0, 1, BridgeDirection::Vertical)).unwrap(); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0)); + assert_eq!(islands_left(&b), 0); +} + +#[test] +fn extra_bridges_stay_two_widths_apart() { + // Sixteen 10 pt bridges have no room around a hole 60 pt across with 20 pt between them. + let width = 10.0; + let found = contours(&letter_o(), width).unwrap(); + let (bridges, unjoined) = choose(&found, spec(width, 16, BridgeDirection::Shortest), MAX_WORK).unwrap(); + assert!(bridges.len() > 4 && bridges.len() < 16, "{}", bridges.len()); + assert_eq!(unjoined, 0); + for (i, a) in bridges.iter().enumerate() { + for b in &bridges[i + 1..] { + assert!(a.middle.distance(b.middle) >= width * 2.0, "{:?} {:?}", a.middle, b.middle); + } + } +} + +#[test] +fn bridges_found_before_the_budget_runs_out_are_kept() { + let found = contours(&letter_o(), 4.0).unwrap(); + // The least work that joins the island with one bridge also gives one with up to 16 asked + // for: the extra bridges stop where the budget ends. + let least = (1..100_000).find(|&work| choose(&found, spec(4.0, 1, BridgeDirection::Vertical), work).is_ok()).unwrap(); + let (bridges, unjoined) = choose(&found, spec(4.0, 16, BridgeDirection::Vertical), least).unwrap(); + assert_eq!((bridges.len(), unjoined), (1, 0)); + assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex))); +} + +#[test] +fn curves_are_followed_within_a_twentieth_of_the_width() { + let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001)); + let mut counts = vec![]; + for (width, tolerance) in [(0.01, 0.002), (1.0, 0.05), (20.0, 0.25)] { + let found = contours(&circle, width).unwrap(); + let points = &found[0].points; + // The sag of each chord below the circle. + let sag = (0..points.len()).map(|i| 100.0 - points[i].midpoint(points[(i + 1) % points.len()]).to_vec2().hypot()).fold(0.0, f64::max); + assert!(sag <= tolerance + 0.002, "{width}: {sag}"); + counts.push(points.len()); + } + assert!(counts[0] > counts[1] && counts[1] > counts[2], "{counts:?}"); +} + +#[test] +fn open_outlines_are_closed_as_a_fill_closes_them() { + let mut art = letter_o(); + art.subpaths[0].closed = false; + let b = run(&art, spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0)); +} + +#[test] +fn art_too_large_is_refused_before_it_is_combined() { + let far = PathData::new(vec![rect(0.0, 0.0, 1e8, 1e8)]); + let points: Vec<Point> = (0..=MAX_STENCIL_SEGMENTS).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64)).collect(); + let dense = PathData::new(vec![SubPath::polyline(&points, true)]); + assert!(too_large(&[(&far, FillRule::EvenOdd)]) && too_large(&[(&dense, FillRule::EvenOdd)])); + assert!(!too_large(&[(&letter_o(), FillRule::EvenOdd), (&letter_o(), FillRule::NonZero)])); +} + +#[test] +fn outlines_nested_too_deep_to_place_are_refused() { + // Rings of 190-sided polygons, one inside the next: placing each inside the others takes + // about 95 million tests of a point against an edge with 1000 of them, and 8 million with 300. + let polygon = |r: f64| { + let pts: Vec<Point> = (0..190).map(|i| (i as f64 / 190.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| Point::new(c * r, s * r)).collect(); + SubPath::polyline(&pts, true) + }; + let nested = |n: usize| PathData::new((0..n).map(|i| polygon(10.0 + i as f64)).collect()); + assert!(contours(&nested(300), 1.0).is_ok()); + assert!(matches!(contours(&nested(1000), 1.0), Err(PathOpsError::TooComplex))); +} + +#[test] +fn a_curved_o_is_bridged_at_its_thinnest() { + // Two circles, the hole 8 pt above the middle: the O is 2 pt thick at the top and 18 pt at + // the bottom. + let circle = |cy: f64, r: f64| PathData::from_bezpath(&kurbo::Circle::new((100.0, cy), r).to_path(0.01)); + let mut art = circle(100.0, 60.0); + art.subpaths.extend(circle(92.0, 50.0).subpaths); + let b = run(&art, spec(1.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, islands_left(&b)), (1, 1, 0)); + assert!(!inside(&b.path, 100.0, 41.0), "it crosses the thin top"); + assert!(inside(&b.path, 100.0, 151.0), "the thick bottom stays"); +} + +#[test] +fn art_with_too_many_outlines_is_refused() { + let squares = (0..MAX_STENCIL_CONTOURS + 1) + .map(|i| { + let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0); + rect(x, y, x + 1.0, y + 1.0) + }) + .collect(); + let region = PathData::new(squares); + assert!(matches!(contours(®ion, 1.0), Err(PathOpsError::TooComplex))); + let mut fewer = region.clone(); + fewer.subpaths.pop(); + assert_eq!(contours(&fewer, 1.0).unwrap().len(), MAX_STENCIL_CONTOURS); +} + +#[test] +fn art_as_large_as_a_document_holds_is_followed_and_larger_art_is_refused() { + // An O 8000000 pt across, the most a document holds, with a 60 pt hole near its edge, followed + // within 0.002 pt. + let circle = |c: (f64, f64), r: f64| PathData::from_bezpath(&kurbo::Circle::new(c, r).to_path(0.01)); + let mut art = circle((0.0, 0.0), 4e6); + art.subpaths.extend(circle((0.0, 0.0), 2e6).subpaths); + art.subpaths.extend(circle((4e6 - 100.0, 0.0), 30.0).subpaths); + let found = contours(&art, 0.01).unwrap(); + assert_eq!(found.iter().filter(|c| c.hole).count(), 2); + let far = PathData::new(vec![rect(0.0, 0.0, 1e8, 1e8)]); + assert!(matches!(contours(&far, 0.01), Err(PathOpsError::TooComplex))); + let r = stencil_bridges(&[(&far, FillRule::EvenOdd)], spec(4.0, 1, BridgeDirection::Vertical)); + assert!(matches!(r, Err(PathOpsError::TooComplex))); +} + +#[test] +fn art_with_too_many_anchors_is_refused() { + let points: Vec<Point> = (0..=MAX_STENCIL_SEGMENTS).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64)).collect(); + let art = PathData::new(vec![SubPath::polyline(&points, true)]); + let r = stencil_bridges(&[(&art, FillRule::EvenOdd)], spec(4.0, 1, BridgeDirection::Vertical)); + assert!(matches!(r, Err(PathOpsError::TooComplex))); +} + +#[test] +fn following_stops_past_the_limit_and_allows_the_closing_point() { + // A closed square: its four corners and the start again. + let square = rect(0.0, 0.0, 10.0, 10.0); + let mut bp = BezPath::new(); + square.to_bezpath_into(&mut bp); + assert_eq!(follow(&bp, 0.01, 4).unwrap().len(), 5); + assert!(matches!(follow(&bp, 0.01, 3), Err(PathOpsError::TooComplex))); +} + +#[test] +fn bad_widths_and_counts_are_refused() { + let art = letter_o(); + for s in [spec(0.0, 1, BridgeDirection::Vertical), spec(f64::NAN, 1, BridgeDirection::Vertical), spec(4.0, 0, BridgeDirection::Vertical)] { + assert!(stencil_bridges(&[(&art, FillRule::EvenOdd)], s).is_err(), "{s:?}"); + } +} diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index ced5eb33a8..522f82bd2b 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -5,6 +5,7 @@ pub mod cleanup; pub mod color; pub mod compounds; pub mod contour; +pub mod cutting; pub mod document; pub mod enclosing_lasso; pub mod image; @@ -25,6 +26,7 @@ pub fn plugin_menu() -> Vec<Item> { Item::Sub("Color Effects", color::menu()), Item::Sub("Compound Shapes", compounds::menu()), Item::Sub("Contour", contour::menu()), + Item::Sub("Cutting Tools", cutting::menu()), Item::Sub("Document Tools", document::menu()), Item::Sub("Enclosing Lasso", enclosing_lasso::menu()), Item::Sub("Image Tools", image::menu()), diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs new file mode 100644 index 0000000000..82fd2d6e3b --- /dev/null +++ b/crates/ui-egui/src/addons/cutting.rs @@ -0,0 +1,20 @@ +//! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil +//! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options +//! of `addon.cutting.bridges`. + +use serde_json::json; + +use crate::menus::Item; + +/// Stencil Bridges' command. +pub const BRIDGES: &str = "addon.cutting.bridges"; +/// Stencil Bridges › Direction: what the command's `direction` takes, by name. +pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")]; + +/// Object › Plug-ins › Cutting Tools. +pub fn menu() -> Vec<Item> { + vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))] +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs new file mode 100644 index 0000000000..d5620f717b --- /dev/null +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -0,0 +1,54 @@ +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +/// Object › Plug-ins › Cutting Tools › Stencil Bridges… opens the command dialog on a 2 pt width, one +/// bridge per island, vertical bridges and Keep Original on; the direction is a list of its three +/// ways; OK cuts the bridges as one undo step, and a message shows when there are no islands. +#[test] +fn stencil_bridges_opens_its_dialog_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let outer = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + let inner = app.run("shape.rectangle", json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [outer, inner]})).unwrap(); + let o = app.run("object.pathfinder.minusFront", json!({})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [o]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let n = steps(&app); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Stencil Bridges…").expect("listed"); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::BRIDGES.to_string(), "Stencil Bridges".to_string())); + assert_eq!((d.f64("width", 0.0), d.f64("count", 0.0), d.str("direction"), d.bool("keepOriginal")), (2.0, 1.0, "vertical".to_string(), true)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Stencil Bridges", "Width:", "Count:", "Direction:", "Vertical", "Keep Original"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + // Each way the list offers is one the command takes. + for (_, way) in super::BRIDGE_DIRECTIONS { + assert!(app.session.execute(super::BRIDGES, &json!({"direction": way, "width": 1000})).is_ok(), "{way}"); + } + assert_eq!(steps(&app), n, "no bridge fits that wide: nothing changed"); + + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + let layers = &app.session.doc().unwrap().doc.layers; + assert!(layers.iter().any(|l| l.name.as_deref() == Some("Stencil Original") && !l.visible), "the original is on a hidden layer"); + + // Run again on the result, which has no islands left: a message, and nothing changes. + open(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), n + 1); + assert!(app.ui.status.contains("no islands"), "{}", app.ui.status); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 3ca205acdb..7c9aaf6873 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -106,6 +106,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], "object.pathfinder.trap" => &["thickness"], + crate::addons::cutting::BRIDGES => &["width"], _ => &[], } } @@ -119,6 +120,7 @@ fn order(command: &str) -> &'static [&'static str] { "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], crate::addons::typetools::RELEASE => &["mode", "keepOriginal"], crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"], + crate::addons::cutting::BRIDGES => &["width", "count", "direction", "keepOriginal"], _ => &[], } } @@ -129,6 +131,7 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> { ("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS), ("type.pathOptions", "alignToPath") => Some(PATH_ALIGN), (crate::addons::typetools::RELEASE, "mode") => Some(crate::addons::typetools::RELEASE_MODES), + (crate::addons::cutting::BRIDGES, "direction") => Some(crate::addons::cutting::BRIDGE_DIRECTIONS), ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 05756a1af6..2fe391b672 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6610,3 +6610,10 @@ breakText Glyphs Глифи # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти Deferred Effects Отложени ефекти + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Инструменти за рязане + Stencil Bridges… Мостчета на шаблона… + Stencil Bridges Мостчета на шаблона + Shortest Crossing Най-късо пресичане + Direction Посока diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a9e705cfac..4b8b340998 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6661,3 +6661,10 @@ shape Open Otvoren # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte Deferred Effects Odgođeni efekti + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Alati za rezanje + Stencil Bridges… Mostići šablona… + Stencil Bridges Mostići šablona + Shortest Crossing Najkraći prijelaz + Direction Smjer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5341805a73..86a6ad4b1a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6888,3 +6888,15 @@ breakText Glyphs Glifs Deferred Effects Efectes ajornats @msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Eines de tall + Stencil Bridges… Ponts de plantilla… + Stencil Bridges Ponts de plantilla + Shortest Crossing Travessia més curta + Direction Direcció +@msg The selected art has no islands to bridge La il·lustració seleccionada no té illes per unir +@msg No bridge of this width fits across the selected art Cap pont d’aquesta amplada no cap a la il·lustració seleccionada +@msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració +@msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9d6fd981e6..5c30649715 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6907,3 +6907,15 @@ breakText Glyphs Glyfy Deferred Effects Odložené efekty @msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Nástroje pro řezání + Stencil Bridges… Můstky šablony… + Stencil Bridges Můstky šablony + Shortest Crossing Nejkratší přechod + Direction Směr +@msg The selected art has no islands to bridge Vybraná kresba nemá žádné ostrůvky k přemostění +@msg No bridge of this width fits across the selected art Přes vybranou kresbu se nevejde žádný můstek této šířky +@msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby +@msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index af95c4119d..ac332632d7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6624,3 +6624,10 @@ breakText Glyphs Glyffer # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter Deferred Effects Udskudte effekter + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Skæreværktøjer + Stencil Bridges… Stencilbroer… + Stencil Bridges Stencilbroer + Shortest Crossing Korteste krydsning + Direction Retning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 55bc587b64..f94db9442a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6494,3 +6494,15 @@ breakText Glyphs Glyphen Deferred Effects Zurückgestellte Effekte @msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Schneidwerkzeuge + Stencil Bridges… Schablonenstege… + Stencil Bridges Schablonenstege + Shortest Crossing Kürzeste Querung + Direction Richtung +@msg The selected art has no islands to bridge Das ausgewählte Bildmaterial hat keine Inseln zum Verbinden +@msg No bridge of this width fits across the selected art Kein Steg dieser Breite passt über das ausgewählte Bildmaterial +@msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden +@msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8732e8d34..34586318a0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6859,3 +6859,15 @@ breakText Glyphs Γλύφοι Deferred Effects Εφέ σε αναμονή @msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Εργαλεία κοπής + Stencil Bridges… Γέφυρες στένσιλ… + Stencil Bridges Γέφυρες στένσιλ + Shortest Crossing Συντομότερη διέλευση + Direction Κατεύθυνση +@msg The selected art has no islands to bridge Το επιλεγμένο γραφικό δεν έχει νησίδες για γεφύρωση +@msg No bridge of this width fits across the selected art Καμία γέφυρα αυτού του πλάτους δεν χωράει στο επιλεγμένο γραφικό +@msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό +@msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7eb51ee4f2..2c85f3e751 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6850,3 +6850,15 @@ breakText Glyphs Glifos Deferred Effects Efectos aplazados @msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Herramientas de corte + Stencil Bridges… Puentes de plantilla… + Stencil Bridges Puentes de plantilla + Shortest Crossing Cruce más corto + Direction Dirección +@msg The selected art has no islands to bridge La ilustración seleccionada no tiene islas que unir +@msg No bridge of this width fits across the selected art Ningún puente de este ancho cabe en la ilustración seleccionada +@msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración +@msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433883a44f..3607d3fe64 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6942,3 +6942,15 @@ breakText Glyphs Merkit Deferred Effects Lykätyt tehosteet @msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Leikkaustyökalut + Stencil Bridges… Sapluunan sillat… + Stencil Bridges Sapluunan sillat + Shortest Crossing Lyhin ylitys + Direction Suunta +@msg The selected art has no islands to bridge Valitussa grafiikassa ei ole yhdistettäviä saarekkeita +@msg No bridge of this width fits across the selected art Näin leveä silta ei mahdu valitun grafiikan poikki +@msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen +@msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6995235edc..f0d97846f7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6902,3 +6902,15 @@ breakText Glyphs Glyphes Deferred Effects Effets différés @msg no plug-in effect is deferred aucun effet de plug-in n’est différé @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Outils de découpe + Stencil Bridges… Ponts de pochoir… + Stencil Bridges Ponts de pochoir + Shortest Crossing Traversée la plus courte + Direction Direction +@msg The selected art has no islands to bridge L’illustration sélectionnée n’a aucun îlot à relier +@msg No bridge of this width fits across the selected art Aucun pont de cette largeur ne tient en travers de l’illustration sélectionnée +@msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration +@msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 574229b446..2b063c2214 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8342,3 +8342,15 @@ theme Appearance अपीयरेंस Deferred Effects स्थगित इफ़ेक्ट्स @msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है। + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools कटिंग टूल्स + Stencil Bridges… स्टेंसिल ब्रिज… + Stencil Bridges स्टेंसिल ब्रिज + Shortest Crossing सबसे छोटा क्रॉसिंग + Direction दिशा +@msg The selected art has no islands to bridge चुने गए आर्टवर्क में ब्रिज से जोड़ने के लिए कोई आइलैंड नहीं है +@msg No bridge of this width fits across the selected art इस चौड़ाई का कोई ब्रिज चुने गए आर्टवर्क के पार नहीं आता +@msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके +@msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e4f100338..23f8a096bb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6891,3 +6891,15 @@ breakText Glyphs Glifák Deferred Effects Elhalasztott effektusok @msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Vágóeszközök + Stencil Bridges… Sablonhidak… + Stencil Bridges Sablonhidak + Shortest Crossing Legrövidebb átkelés + Direction Irány +@msg The selected art has no islands to bridge A kijelölt grafikán nincsenek összekötendő szigetek +@msg No bridge of this width fits across the selected art Ilyen széles híd nem fér el a kijelölt grafikán +@msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel +@msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a1df85189e..eaae2e0f7e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6624,3 +6624,10 @@ breakText Glyphs Glif # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda Deferred Effects Efek yang ditunda + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Alat pemotongan + Stencil Bridges… Jembatan Stensil… + Stencil Bridges Jembatan Stensil + Shortest Crossing Persilangan terpendek + Direction Arah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c1400c91e3..773ed4a83e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6528,3 +6528,15 @@ breakText Glyphs Glifi Deferred Effects Effetti rimandati @msg no plug-in effect is deferred nessun effetto plug-in è rimandato @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Strumenti di taglio + Stencil Bridges… Ponti per stencil… + Stencil Bridges Ponti per stencil + Shortest Crossing Attraversamento più breve + Direction Direzione +@msg The selected art has no islands to bridge La grafica selezionata non ha isole da collegare +@msg No bridge of this width fits across the selected art Nessun ponte di questa larghezza entra nella grafica selezionata +@msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica +@msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8a9e0cedc9..799558a1f2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6593,3 +6593,15 @@ breakText Glyphs 字形 Deferred Effects 保留中の効果 @msg no plug-in effect is deferred 保留中のプラグイン効果はありません @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。 + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools カッティングツール + Stencil Bridges… ステンシルブリッジ… + Stencil Bridges ステンシルブリッジ + Shortest Crossing 最短の横断 + Direction 方向 +@msg The selected art has no islands to bridge 選択したアートワークにはブリッジでつなぐアイランドがありません +@msg No bridge of this width fits across the selected art この幅のブリッジは選択したアートワークに収まりません +@msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした +@msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83196486e7..243c79182a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7021,3 +7021,15 @@ font-script Japanese იაპონური Deferred Effects გადადებული ეფექტები @msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools ჭრის ხელსაწყოები + Stencil Bridges… ტრაფარეტის ხიდები… + Stencil Bridges ტრაფარეტის ხიდები + Shortest Crossing უმოკლესი გადაკვეთა + Direction მიმართულება +@msg The selected art has no islands to bridge მონიშნულ ნახატს არ აქვს დასაკავშირებელი კუნძულები +@msg No bridge of this width fits across the selected art ამ სიგანის ხიდი მონიშნულ ნახატზე არ ეტევა +@msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს +@msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 060c421f28..2c35ed37d6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6489,3 +6489,15 @@ breakText Glyphs 글리프 Deferred Effects 보류된 효과 @msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다 @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools 커팅 도구 + Stencil Bridges… 스텐실 브리지… + Stencil Bridges 스텐실 브리지 + Shortest Crossing 최단 가로지르기 + Direction 방향 +@msg The selected art has no islands to bridge 선택한 아트워크에 연결할 섬이 없습니다 +@msg No bridge of this width fits across the selected art 이 너비의 브리지가 선택한 아트워크에 들어가지 않습니다 +@msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다 +@msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다 +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e494230f8c..992cbfec61 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6675,3 +6675,10 @@ breakText Glyphs Glyphs # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen Deferred Effects Uitgestelde effecten + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Snijgereedschap + Stencil Bridges… Sjabloonbruggen… + Stencil Bridges Sjabloonbruggen + Shortest Crossing Kortste oversteek + Direction Richting diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 052d39351a..4dd4d50a17 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6763,3 +6763,15 @@ breakText Glyphs Glify Deferred Effects Odroczone efekty @msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Narzędzia do cięcia + Stencil Bridges… Mostki szablonu… + Stencil Bridges Mostki szablonu + Shortest Crossing Najkrótsze przejście + Direction Kierunek +@msg The selected art has no islands to bridge Zaznaczona grafika nie ma wysp do połączenia +@msg No bridge of this width fits across the selected art Żaden mostek o tej szerokości nie mieści się w zaznaczonej grafice +@msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki +@msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f78a08845..9894e8a8ea 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6980,3 +6980,15 @@ swatch Yellow Green Verde-amarelado Deferred Effects Efeitos Adiados @msg no plug-in effect is deferred nenhum efeito de plug-in está adiado @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Ferramentas de Corte + Stencil Bridges… Pontes de Estêncil… + Stencil Bridges Pontes de Estêncil + Shortest Crossing Travessia mais curta + Direction Direção +@msg The selected art has no islands to bridge A arte selecionada não tem ilhas para unir +@msg No bridge of this width fits across the selected art Nenhuma ponte desta largura cabe na arte selecionada +@msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte +@msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e50ebf68a0..7e2539c3a8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6996,3 +6996,15 @@ shape Open Открытый Deferred Effects Отложенные эффекты @msg no plug-in effect is deferred нет отложенных эффектов модулей @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Инструменты резки + Stencil Bridges… Мостики трафарета… + Stencil Bridges Мостики трафарета + Shortest Crossing Кратчайшее пересечение + Direction Направление +@msg The selected art has no islands to bridge В выделенной графике нет островков для соединения +@msg No bridge of this width fits across the selected art Мостик такой ширины не помещается поперёк выделенной графики +@msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики +@msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 79725b4435..57f26551d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8387,3 +8387,15 @@ digits Hindi Hindi Deferred Effects Uppskjutna effekter @msg no plug-in effect is deferred ingen plugineffekt är uppskjuten @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Skärverktyg + Stencil Bridges… Schablonbryggor… + Stencil Bridges Schablonbryggor + Shortest Crossing Kortaste korsning + Direction Riktning +@msg The selected art has no islands to bridge Den markerade teckningen har inga öar att förbinda +@msg No bridge of this width fits across the selected art Ingen brygga med den här bredden får plats över den markerade teckningen +@msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen +@msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 05020e1676..8b4a93439a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6863,3 +6863,15 @@ breakText Glyphs Glifler Deferred Effects Ertelenen Efektler @msg no plug-in effect is deferred ertelenen eklenti efekti yok @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Kesim Araçları + Stencil Bridges… Şablon Köprüleri… + Stencil Bridges Şablon Köprüleri + Shortest Crossing En kısa geçiş + Direction Yön +@msg The selected art has no islands to bridge Seçili çizimde köprüyle bağlanacak ada yok +@msg No bridge of this width fits across the selected art Bu genişlikte bir köprü seçili çizime sığmıyor +@msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı +@msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13e60355b5..101e853fb2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6508,3 +6508,15 @@ breakText Glyphs Гліфи Deferred Effects Відкладені ефекти @msg no plug-in effect is deferred немає відкладених ефектів модулів @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Інструменти різання + Stencil Bridges… Містки трафарету… + Stencil Bridges Містки трафарету + Shortest Crossing Найкоротший перетин + Direction Напрямок +@msg The selected art has no islands to bridge У виділеній графіці немає острівців для з’єднання +@msg No bridge of this width fits across the selected art Місток такої ширини не вміщується впоперек виділеної графіки +@msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки +@msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cb5aaf727..a0e2f448b2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6509,3 +6509,15 @@ breakText Glyphs Glyph Deferred Effects Hiệu ứng bị hoãn @msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Công cụ cắt + Stencil Bridges… Cầu nối khuôn tô… + Stencil Bridges Cầu nối khuôn tô + Shortest Crossing Đường cắt ngắn nhất + Direction Hướng +@msg The selected art has no islands to bridge Hình vẽ đã chọn không có đảo nào cần nối +@msg No bridge of this width fits across the selected art Không có cầu nối nào rộng như vậy vừa với hình vẽ đã chọn +@msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ +@msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 286b3a0ca4..c3ee18c67f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6745,3 +6745,15 @@ breakText Glyphs 字形 Deferred Effects 延迟的效果 @msg no plug-in effect is deferred 没有延迟的增效工具效果 @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。 + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools 切割工具 + Stencil Bridges… 镂空版连接桥… + Stencil Bridges 镂空版连接桥 + Shortest Crossing 最短跨越 + Direction 方向 +@msg The selected art has no islands to bridge 所选图稿没有需要连接的孤岛 +@msg No bridge of this width fits across the selected art 此宽度的连接桥无法放入所选图稿 +@msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域 +@msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间 +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c819eccd10..5ea3ca3396 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6543,3 +6543,15 @@ breakText Glyphs 字符 Deferred Effects 延後的效果 @msg no plug-in effect is deferred 沒有延後的增效模組效果 @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 開啟時延後了 {n} 個增效模組效果。效果 › 重新整理延後的效果可執行它們。 + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools 切割工具 + Stencil Bridges… 鏤空版連接橋… + Stencil Bridges 鏤空版連接橋 + Shortest Crossing 最短跨越 + Direction 方向 +@msg The selected art has no islands to bridge 所選圖稿沒有需要連接的孤島 +@msg No bridge of this width fits across the selected art 此寬度的連接橋無法放入所選圖稿 +@msg Some islands could not be joined to the area around the art 部分孤島無法連接到圖稿周圍的區域 +@msg `width` must be from {lo} to {hi} pt `width` 必須介於 {lo} 到 {hi} pt 之間 +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必須是介於 1 到 {MAX_BRIDGES_PER_ISLAND} 的整數 diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..ca34395dc3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9190,6 +9190,60 @@ whole points and Escape cancels. A drag keeps a Contour's own corners and miter options `corners` and `miterLimit` (`tool.setOption`). A border with a gap is two Contours of one shape (the gap first, so it ends up in front), baked with `effect.expandAppearance`, then `object.pathfinder.minusFront`. +## Cutting Tools add-on + +Cutting Tools is a built-in add-on that prepares art for cutting. Its command is `addon.cutting.bridges`; in the desktop +app it is Object › Plug-ins › Cutting Tools › Stencil Bridges…. + +### Stencil Bridges + +`addon.cutting.bridges` cuts bridges out of the selected art so that a stencil cut from it holds together. An island is +an area the art encloses, such as the inside of an O, A or 8 or a hole in a shape; cut out of a sheet, it would fall +away. A bridge is a strip of the sheet left uncut across the art, which keeps the island attached +([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). The rules below are VectorCraft's own. + +- The art is measured by the area its outlines enclose, whatever their paint: paths and compound paths with their fill + rule (open paths closed as a fill closes them), groups by their members, and type by its glyph outlines. Hidden + objects, and the members of hidden groups, are left out. The type object itself is not converted. An outline that + encloses less than the square of the curve tolerance (below) counts as part of the area around it. +- Every island is joined to the area around the art, directly or through another island, by the shortest crossings + that fit (among crossings of about the same length, the one nearest the middle of the island). Then each island gets + more bridges, up to `count`: each at least two widths from the island's other bridges and no longer than twice the + shortest crossing a bridge fits plus `width`. An island too narrow for a strip `width` wide gets no bridge, and islands that a + bridge could join only to each other and not to the sheet get none either. `unbridged` counts the islands the cut + result still encloses. +- The result is one compound path (a plain path when one outline is left) with the strips cut out of its outlines, + painted as the topmost selected shape, in the place of the topmost selected object that has outlines. It is selected. + Selected objects with no outlines in the art, such as hidden objects or type holding only spaces, stay where they are. +- With `keepOriginal` the selected objects that make up the art move to a new hidden layer named "Stencil Original" + ("Stencil Original 2" and so on when that name is taken), above the other layers, in their stacking order and outside + the groups that held them; with false they are replaced. +- Art with no islands, or with islands that no bridge of the width fits, is left as it is: the result carries a + `warning` and no undo step is made. Otherwise the run is one undo step, with a `warning` when the cut result still + encloses some islands. +- Curves are followed in straight segments within a twentieth of `width` (0.002 to 0.25 pt); a curve that would need + more than 4096 segments gets a larger tolerance. Art that reaches more than 10000000 pt from the origin or has more + than 200000 anchors is refused before it is combined. Art is also refused when its combined area has more than 4000 + outlines, when its outlines need more than 200000 straight segments, and when finding which outlines lie inside + which, or measuring the crossings until every island is joined, takes more work than a fixed budget allows. When the + budget runs out while extra bridges are being added, the bridges found so far are kept. + +| Parameter | Values (default) | +|---|---| +| `width` | each bridge's width, 0.01 to 1000 pt, as a number of points or a string with a unit such as `"2 mm"` (2) | +| `count` | the bridges each island gets, a whole number from 1 to 16; fewer where it has no room, and more where other islands are joined to the sheet through it (1) | +| `direction` | `vertical`, `horizontal`, or `shortest` (the shortest crossing among 12 directions 15° apart) (`vertical`) | +| `keepOriginal` | true; false replaces the selected art | + +→ `{id (the new path, or null), islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}`. +Stencil Bridges… opens the command dialog (`ui.paramDialog`) on Width, Count, Direction (Vertical, Horizontal, Shortest +Crossing) and Keep Original. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":"2 mm"}}} +{"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":3,"count":2,"direction":"shortest","keepOriginal":false}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..c79f3dc9a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1939,6 +1939,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, not a feature of the reference app (it has no command for this), so no area score moves. Object › Plug-ins › Type Tools › Release Type on a Path… splits each selected type on a path into point type holding the same text and the plain path it was set on, or keeps only one of them (#5532). The path keeps its exact shape (open or closed, anchors and handles, the object's transform), layer and place in the stacking order, with no fill and no stroke since type paths carry no paint of their own. The text keeps its runs, character and paragraph attributes and becomes point type on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options don't apply to point type and are dropped. Keep Original leaves the type on a path and adds the released objects above it. Other objects are left alone and a selection with no type on a path makes no undo step; otherwise the run is one undo step, the released objects are selected, and the dialog's Preview works as Type on a Path Options' does (#5360). Tests: `cmd::addons::typetools::tests::release` in `vectorcraft-engine` (each mode, Keep Original, mixed and empty selections, closed, open, curved and flipped paths, transformed and grouped type, multi-style and unicode text with a centred paragraph, empty type, undo and redo, preview, cancel and commit, bad params refused), `release_type_on_a_path_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Type Tools add-on › Release Type on a Path. +## Cutting Tools add-on: Stencil Bridges (2026-11-12) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Stencil Bridges… cuts strips of a set width (0.01 to 1000 pt, 2 by default) out of the selected art so that a stencil cut from it keeps its islands, such as the inside of an O or a hole in a shape, attached to the sheet ([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). Every island is joined to the sheet, directly or through another island, by the shortest crossings that fit, then gets more bridges spread around it up to the count asked for (1 to 16); bridges run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. The art is measured by the area its outlines enclose (hidden objects left out), type by its glyph outlines without converting the type, and becomes one compound path with the gaps cut; the selected objects that make up the art go to a new hidden layer, Stencil Original, or are replaced, and selected objects with no outlines stay where they are. Art with no islands is left as it is with a message and no undo step, islands the cut result still encloses are counted and reported, and the run is one undo step. Tests: `addons::stencil::tests` in `vectorcraft-pathops` (an O, two holes, nested islands, curved outlines, width, direction, the shortest crossing at an angle, more bridges per island and their spacing, narrow islands, slivers, outlines that touch at a corner, the curve tolerance, open outlines, limits and the work budget), `cmd::addons::cutting::stencil::tests` in `vectorcraft-engine` (the hidden layer and undo, replacing, width with units, type, hidden group members, objects with no outlines, the result's paint, kind and place in the stacking order, no islands, narrow islands, bad params refused), `stencil_bridges_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Stencil Bridges. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. 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 36/72] 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 37/72] 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 27d63af68a0ce446c6375ead2d5390c419f2f1cb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:50:45 -0800 Subject: [PATCH 38/72] 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 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 39/72] 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 5d8065e9296e475f07f565cb055a9db6d56bb180 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:04:22 -0800 Subject: [PATCH 40/72] 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 41/72] 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 42/72] 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 43/72] 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 44/72] =?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 45/72] 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 46/72] 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 47/72] 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 48/72] 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 49/72] 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 50/72] 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 51/72] 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 52/72] 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 53/72] 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 54/72] 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 55/72] 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 56/72] 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 57/72] 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 58/72] 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 59/72] 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 60/72] 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 61/72] 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 62/72] 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 63/72] 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 c35d5f5b347c360e0cc2a33f6ad0082df7f7ec00 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 01:03:41 -0800 Subject: [PATCH 64/72] Read roots: links.findFiles and image.info follow the configured read roots With automation read roots in force, links.findFiles and text.findFontFiles replies, including later polls and stops of a search another client started, leave out the files found outside the caller's read roots, a sought link's saved path outside them and the folder searched when it is outside them. A failed search then gives its reason without the path. The rows keep their names, ids and status, and the counts stay. image.info gives link: null for a linked file outside the read roots, as links.info does. text.addFontFiles gives folder: null when the Fonts folder is outside the read roots and names the copies there by their file names; the fonts are still copied. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/findfiles/tests.rs | 12 ++- crates/engine/src/cmd/findfiles/walk.rs | 66 ++++++++++--- crates/engine/src/cmd/fontfiles.rs | 28 ++++-- crates/engine/src/cmd/linkfiles.rs | 23 +++-- crates/engine/src/cmd/links.rs | 4 +- crates/engine/src/cmd/place.rs | 6 +- crates/engine/src/tests/fontfiles.rs | 37 ++++++++ crates/engine/src/tests/linkfiles.rs | 112 ++++++++++++++++++++++- crates/engine/tests/font_folder.rs | 7 ++ docs/mcp.md | 8 ++ docs/target-app-parity.md | 1 + 11 files changed, 266 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index ec3ce57e3a..bf51ebdac7 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() { assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}"); let p = search(&root, "missing", Limits::default(), 1, &[]); assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}"); + // Each failure has the same reason without the folder's path. + let briefs = [ + ("missing", "the folder can't be read"), + ("file.ttf", "the path isn't a folder"), + ("refused", "VectorCraft doesn't search other apps' or the system's folders"), + ("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"), + ]; + for (picked, why) in briefs { + assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}"); + } } #[test] @@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() { let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); if enforced { - assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}"); + assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}"); } else { assert_eq!(found(&root, &p), ["locked/y.ttf"]); } diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index 8d35515717..ae05a04838 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024; /// How a search ends when every walker thread stopped on a bug. const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)"; +/// Why a search couldn't start, without the folder's path ([`Progress::failure`]). +const UNREADABLE: &str = "the folder can't be read"; +const NOT_A_FOLDER: &str = "the path isn't a folder"; +const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users"; +const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders"; +const UNLISTED: &str = "the folder can't be listed"; + /// What a search looks for. pub trait Visitor: Send + Sync { /// Whether the regular file named `name` is to be read: asked of the files a folder lists until @@ -77,8 +84,9 @@ pub enum End { /// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item /// keeps at most [`Limits::per_item`] files, without ending the search. Limit, - /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. - Failed(String), + /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why, + /// and the same without the folder's path. + Failed(String, &'static str), } /// Where a search stands ([`Search::progress`]). @@ -114,7 +122,7 @@ impl Progress { None => "searching", Some(End::Done | End::Found) => "done", Some(End::Stopped | End::Time | End::Limit) => "stopped", - Some(End::Failed(_)) => "failed", + Some(End::Failed(..)) => "failed", } } @@ -131,10 +139,38 @@ impl Progress { /// Why a failed search failed. pub fn error(&self) -> Option<&str> { match &self.end { - Some(End::Failed(e)) => Some(e), + Some(End::Failed(e, _)) => Some(e), _ => None, } } + + /// Why a failed search failed, without the folder's path. + pub fn failure(&self) -> Option<&'static str> { + match &self.end { + Some(End::Failed(_, why)) => Some(why), + _ => None, + } + } + + /// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]), + /// for a reply to it: the files found inside them. Returns the folder searched (`given` until a + /// walker checked it) and why the search failed, when that folder lies inside them too; + /// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in + /// force, everything is kept. + pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) { + let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() }; + if crate::file_access::current().is_none() { + return (Some(folder), self.error().map(str::to_string)); + } + // A path that isn't valid text is never shown: its lossy form names another file. + let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + self.hits.retain(|(path, _)| readable(path)); + if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) { + (Some(folder), self.error().map(str::to_string)) + } else { + (None, self.failure().map(str::to_string)) + } + } } /// Walker threads shared out among searches: all of them together run at most `cap` threads. @@ -306,7 +342,7 @@ pub(crate) fn start_in( return Err("the search couldn't start: try again in a moment".into()); } if w.alive == 0 && w.end.is_none() { - shared.finish(&mut w, End::Failed(INTERNAL.into())); + shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } } Ok(Search { shared }) @@ -401,13 +437,13 @@ impl Shared { fn prepare(&self, root: &Path) { let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system)); let path = root.display(); - let checked = match std::fs::canonicalize(root) { - Err(e) => Err(format!("{path}: {e}")), - Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")), + let (checked, brief) = match std::fs::canonicalize(root) { + Err(e) => (Err(format!("{path}: {e}")), UNREADABLE), + Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER), Ok(folder) => match rules.refusal(&folder) { - None => Ok(folder), - Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), - Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), + None => (Ok(folder), INTERNAL), + Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED), + Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS), }, }; let mut w = self.lock(); @@ -416,7 +452,7 @@ impl Shared { w.folder = folder.clone(); w.stack.push((folder, 0)); } - Err(e) => self.finish(&mut w, End::Failed(e)), + Err(e) => self.finish(&mut w, End::Failed(e, brief)), } } @@ -430,7 +466,7 @@ impl Shared { let mut w = self.lock(); // The picked folder itself: the search can't run, and says why. if depth == 0 { - self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()))); + self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED)); } else { w.unreadable += 1; w.folders += 1; @@ -610,7 +646,7 @@ fn walker(s: &Shared) { if done.is_err() { let mut w = s.lock(); match job { - Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())), + Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)), Job::List(..) => w.unreadable += 1, } } @@ -640,7 +676,7 @@ impl Drop for Alive { let mut w = self.0.lock(); w.alive = w.alive.saturating_sub(1); if w.alive == 0 && w.end.is_none() { - self.0.finish(&mut w, End::Failed(INTERNAL.into())); + self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } drop(w); self.0.pool.release(1); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 309347985c..0ccf1ab94a 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> { "Find Font Files", [], None, - "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Add Font Files", [], None, - "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", always, add ), @@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { /// Where the session's last search stands (`text.findFontFiles` without `folder`). fn state(s: &Session) -> Value { let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) }; - let p = f.search.progress(); + let mut p = f.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&f.folder); let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()]; for (path, items) in &p.hits { for i in items { @@ -293,16 +295,18 @@ fn state(s: &Session) -> Value { json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files }) }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts, + "id": f.id, "state": p.state(), "fonts": fonts, "searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out @@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { // VectorCraft's own Fonts folder: no automation root applies to it. crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?; let report = copy_into(&files, dir); - let pairs = |v: &[(PathBuf, PathBuf)]| { - v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>() + // The caller sees the Fonts folder and the copies in it only where its read roots reach. + let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + let shown = |p: &Path| -> String { + let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() }; + shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() }; + let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>(); let mut out = json!({ - "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept), - "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(), + "folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept), + "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(), }); if !report.copied.is_empty() || !report.kept.is_empty() { // The fonts are cataloged and type set in them redraws, as Refresh Font List does it. diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 5b5a6aa48a..f2ce9d4d12 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> { "Find Linked Files", [], None, - "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find )] @@ -52,6 +52,8 @@ pub(crate) struct LinkSearch { pub(crate) id: u64, /// The folder as given. pub(crate) folder: String, + /// The path of the document the links were taken from, which a relative link path starts from. + pub(crate) doc: Option<String>, pub(crate) links: Vec<Sought>, /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>, @@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { if links.is_empty() { return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" })); } + let doc = super::links::LinkBase::of(st).doc.map(str::to_string); // A new search stops the last one. s.link_search = None; let threads = s.search_threads.unwrap_or_else(findfiles::threads); @@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { let visitor = Arc::new(LinkFiles::new(&links, sizes.clone())); let search = findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?; - s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search }); + s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search }); Ok(state(s)) } @@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize { /// Where the session's last search stands (`links.findFiles` without `folder`). fn state(s: &Session) -> Value { let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) }; - let p = l.search.progress(); + let mut p = l.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&l.folder); let n = l.links.len(); let sizes = lock(&l.sizes).clone(); // Per linked file: the files found, each with whether it is the same file. @@ -267,19 +272,23 @@ fn state(s: &Session) -> Value { f }) .collect(); - json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files }) + let mut row = json!({ "name": link.name, "ids": link.ids, "files": files }); + super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref()); + row }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": l.id, "state": p.state(), "folder": folder, "links": links, + "id": l.id, "state": p.state(), "links": links, "searched": { "folders": p.folders, "files": p.files, "read": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..89c0afefcf 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec< /// May a reply to the caller asking now carry the link path `path` (of the document saved at /// `doc_path`)? Always without read roots in force; with them, only when the file it names (a /// relative path taken from the document's folder) lies inside them. -fn path_shown(path: &str, doc_path: Option<&str>) -> bool { +pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool { crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) } /// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. -fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { +pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { if path_shown(path, doc_path) { row[key] = json!(path); } else if let Some(o) = row.as_object_mut() { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a6e7f67286..5d56a2b646 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> { "Image Info", [], None, - "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", + "{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", has_doc, image_info ), @@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> { let [a, b, c, d, ..] = im.xf.as_coeffs(); let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l); let size = n.geometric_bounds().unwrap_or_default(); + // A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info. + let doc_path = super::links::LinkBase::of(st).doc; Ok(json!({ "id": id.0, "name": n.display_name(), "linked": im.link.is_some(), - "link": im.link.as_ref().map(|l| &l.path), + "link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)), "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), "pixelWidth": im.width, "pixelHeight": im.height, diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs index f31df35eb1..8bef8733b5 100644 --- a/crates/engine/src/tests/fontfiles.rs +++ b/crates/engine/src/tests/fontfiles.rs @@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}"); } +/// A caller confined by read roots sees, in the replies to a search another caller started, only +/// the files found inside its read roots, and the folder searched and a failed search's path only +/// when they are inside them. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + for dir in [&home, &wide] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Findme.otf"), &font).unwrap(); + } + let mut s = session_for(&root); + let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap(); + let (narrow, broad) = (roots(&[&home]), roots(&[&root])); + let ask = |s: &mut Session, r: &Arc<crate::file_access::AutomationRoots>, p: Value| { + crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap()) + }; + // A font no file has keeps the search going through every folder. + let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]); + ask(&mut s, &broad, json!({"folder": root, "fonts": fonts})); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts})); + assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide"))); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn junk_search_params_never_fail_internally() { let root = folder("junk"); diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index 695ea41419..ef9127042a 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() { let (tx, rx) = mpsc::channel(); let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }]; - s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search }); + s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search }); let until = Instant::now() + WAIT; while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until { std::thread::sleep(Duration::from_millis(2)); @@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() { drop(tx); } +/// A caller confined by read roots sees, in the replies to a search another caller started and to +/// its own, only the paths inside its read roots: the files found outside them, a link's saved path +/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without +/// roots, the replies are as before. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let mut s = session_for(&root); + let photo = png(40, 30, RED); + write(&root.join("lrf-away/photo.png"), &photo); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap(); + // Files of the name with other contents: the search reads every folder. + let other = png(40, 30, BLUE); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + write(&home.join("photo.png"), &other); + write(&wide.join("photo.png"), &other); + let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap(); + let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[])); + let ask = |s: &mut Session, r: &Arc<crate::file_access::AutomationRoots>, p: Value| { + crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap()) + }; + let hidden = |r: &Value| { + let text = r.to_string(); + assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + let row = &r["links"][0]; + assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}"); + }; + // Started with the broad roots, over the whole folder. + let r = ask(&mut s, &broad, json!({"folder": root})); + assert_eq!(r["folder"], json!(root), "{r}"); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + let r = ask(&mut s, &write_only, json!({})); + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([]), "{r}"); + // Without roots, every path shows. + let r = s.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}"); + assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}"); + // The narrow caller's own search shows the folder it gave. + let r = ask(&mut s, &narrow, json!({"folder": home})); + assert_eq!(r["folder"], json!(home), "{r}"); + finished(&mut s); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}"); + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")})); + let r = finished(&mut s); + assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}"); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}"); + hidden(&r); + let _ = std::fs::remove_dir_all(&root); +} + +/// A link's saved path relative to the document is shown to a confined caller when it leads to a +/// file inside its read roots, and left out when `..` leads out of them. +#[test] +fn a_relative_link_path_is_judged_from_the_documents_folder() { + let root = folder("read-roots-relative"); + let mut s = session_for(&root); + let home = root.join("home"); + write(&root.join("lrf-away/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::create_dir_all(&home).unwrap(); + s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap(); + for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] { + s.edit("relative path", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind { + im.link.as_mut().unwrap().path = saved.into(); + } + Ok(()) + }) + .unwrap(); + let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap()); + assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}"); + finished(&mut s); + } + let _ = std::fs::remove_dir_all(&root); +} + +/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link` +/// null), as `links.info` leaves it out, and keeps the other fields. +#[test] +fn image_info_leaves_out_a_link_outside_the_read_roots() { + let root = folder("read-roots-info"); + let mut s = session_for(&root); + let pic = root.join("lrf-away/photo.png"); + write(&pic, &png(40, 30, RED)); + let id = placed(&mut s, &pic); + std::fs::create_dir_all(root.join("home")).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap()); + let r = ask(&mut s, "image.info"); + assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}"); + assert!(!r.to_string().contains("lrf-away"), "{r}"); + let info = ask(&mut s, "links.info"); + assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}"); + assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic)); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn relinking_with_copy_puts_the_file_in_the_documents_folder() { let root = folder("copy"); diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs index b58e79fc17..11d152fb4d 100644 --- a/crates/engine/tests/font_folder.rs +++ b/crates/engine/tests/font_folder.rs @@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { assert!(st.revision > revision && !st.is_dirty()); assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0); + // A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the + // reply names the copy by its file name only, without the folder. + let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap(); + let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap(); + assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}"); + assert!(!r.to_string().contains("App Fonts"), "{r}"); + // The same file again is kept, not copied a third time. let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}"); diff --git a/docs/mcp.md b/docs/mcp.md index 62b8052a2a..0a2b114487 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -201,6 +201,14 @@ 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. +- `image.info` gives `link: null` for a linked file outside the read roots, as `links.info` leaves it out; its id, + name, `linked` and the other fields stay. `links.findFiles` and `text.findFontFiles` replies, including later + `{}` and `{stop: true}` calls on a search another client started, leave out the files found outside the read + roots (with their `size` and `match`), a sought link's `path` outside them, and `folder` when the folder searched + is outside them; `error` then gives the reason without the path (for example `the folder can't be read`). The + rows keep their `name`, `ids`, family, style and status, and the counts stay. `text.addFontFiles` gives + `folder: null` when VectorCraft's Fonts folder is outside the read roots, and only the file name in `copied` and + `kept` `to` and in `skipped` `file` for a path outside them; the fonts are still copied. - `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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2a1eb83381..405a4fee2d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -763,6 +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`). + - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.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`. - 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`. From cdcac345a9bbeb25a3678b3eb3dcaa39b1b4060b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 00:45:41 -0800 Subject: [PATCH 65/72] Legacy .ai: type keeps Standard Vertical Roman Alignment and Tate-chu-yoko Horizontal type keeps both marks, and the legacy .ai writer writes such type as text, but the marks were dropped without a warning. Version 7 text has operators for both (section 15.3.4 of the published format specification): Tv for Roman characters upright in vertical writing, and TY for a Kumi block in horizontal text recorded for later vertical use. The writer now writes 1 Tv and 0 Tv, and 1 TY and 0 TY, where a run's mark changes, closes an open Kumi block before the text object ends, and writes neither operator for type without the marks. The legacy .ai reader reads both back onto the same characters. The prolog defines Tv and TY so the file still runs as PostScript. --- crates/eps/src/legacy_ai.rs | 5 ++- crates/eps/src/legacy_ai/prolog.rs | 2 +- crates/eps/src/legacy_ai/tests.rs | 53 ++++++++++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 21 +++++++++++- docs/file-format-parity.md | 3 +- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index e256e305c2..10af4f8f9a 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -33,7 +33,10 @@ //! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx //! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each //! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`, -//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the +//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Standard +//! Vertical Roman Alignment is written as `1 Tv` and `0 Tv`, and Tate-chu-yoko in horizontal +//! type as a Kumi block recorded for later use, `1 TY` … `0 TY` (§15.3.4); neither operator is +//! written for type that has neither mark. Only the //! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs index b0d606bba9..5ee77f89cf 100644 --- a/crates/eps/src/legacy_ai/prolog.rs +++ b/crates/eps/src/legacy_ai/prolog.rs @@ -106,7 +106,7 @@ VCLegacy begin /TE {cleartomark} bd /TZ {cleartomark} bd /To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd /Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd -/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd +/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd /Tv {pop} bd /TY {pop} bd % The page trailer (3): annotatepage marks nothing. /annotatepage {} bd end diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 43b28c934a..d0dd1f64e8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1193,6 +1193,59 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a +/// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back +/// on the same characters, a block is closed before its text object ends, and type with neither +/// mark has neither operator. +#[test] +fn vertical_roman_and_tate_chu_yoko_are_written_and_read_back() { + use vectorcraft_doc::{TextObject, TextRun}; + let plain = style(12.0, Paint::solid(Color::BLACK)); + let roman = vectorcraft_doc::CharStyle { standard_vertical_roman: true, ..plain.clone() }; + let tcy = vectorcraft_doc::CharStyle { tate_chu_yoko: true, ..plain.clone() }; + let both = vectorcraft_doc::CharStyle { tate_chu_yoko: true, standard_vertical_roman: true, ..plain.clone() }; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut marked = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "A", plain.clone()); + for (text, st) in [("12", &tcy), ("b", &roman), ("c", &both), ("34", &tcy)] { + marked.runs.push(TextRun::new(text, st.clone())); + } + let next = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "D", plain.clone()); + for t in [marked, next] { + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("1 TY\n").count(), text.matches("0 TY\n").count()), (2, 2), "{text}"); + assert_eq!((text.matches("1 Tv\n").count(), text.matches("0 Tv\n").count()), (1, 1), "{text}"); + // The last block ends before its text object does. + assert!(text.contains("(34) Tx\n0 TY\nTO\n"), "{text}"); + let read = texts(&crate::import(&out.bytes).unwrap().document); + let flags = |t: &TextObject| -> Vec<(String, bool, bool)> { + let mut out: Vec<(String, bool, bool)> = vec![]; + for r in &t.runs { + let key = (r.style.tate_chu_yoko, r.style.standard_vertical_roman); + match out.last_mut() { + Some(last) if (last.1, last.2) == key => last.0.push_str(&r.text), + _ => out.push((r.text.clone(), key.0, key.1)), + } + } + out + }; + let s = |x: &str| x.to_string(); + assert_eq!( + flags(&read[0].1), + [(s("A"), false, false), (s("12"), true, false), (s("b"), false, true), (s("c"), true, true), (s("34"), true, false)] + ); + assert_eq!(flags(&read[1].1), [(s("D"), false, false)]); + + // Type with neither mark has neither operator. + let text = String::from_utf8(write(&typed()).bytes).unwrap(); + assert!(!text.contains(" Tv\n") && !text.contains(" TY\n"), "{text}"); +} + /// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path /// or end bracket: type on a path with either is written as text, with the warning that they are /// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 8f8fc4ccf2..9bcd5f4f5d 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -39,6 +39,10 @@ pub(super) struct Writer<'a> { in_hidden: bool, /// The fonts type is set in: PostScript name, and whether it's TrueType. pub(super) fonts: Vec<(String, bool)>, + /// The Standard Vertical Roman Alignment the last `Tv` set (it lasts into later text objects). + roman: bool, + /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). + kumi: bool, } impl<'a> Writer<'a> { @@ -59,6 +63,8 @@ impl<'a> Writer<'a> { hidden_art: vec![], in_hidden: false, fonts: vec![], + roman: false, + kumi: false, } } @@ -815,6 +821,10 @@ impl<'a> Writer<'a> { self.line(" Tx"); } } + if self.kumi { + self.line("0 TY"); + self.kumi = false; + } self.line("TO"); } @@ -834,7 +844,8 @@ impl<'a> Writer<'a> { } /// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`, - /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`. + /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`; then `Tv` and `TY` + /// where Standard Vertical Roman Alignment or Tate-chu-yoko changes. fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) { let fill = !st.fill.is_none(); let stroke = st.has_stroke(); @@ -872,6 +883,14 @@ impl<'a> Writer<'a> { let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size)); ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]); self.line("Tl"); + if st.standard_vertical_roman != self.roman { + self.roman = st.standard_vertical_roman; + self.line(if self.roman { "1 Tv" } else { "0 Tv" }); + } + if st.tate_chu_yoko != self.kumi { + self.kumi = st.tate_chu_yoko; + self.line(if self.kumi { "1 TY" } else { "0 TY" }); + } } /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 23a36a7e9a..2f2c88abeb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -155,7 +155,8 @@ private editing data. VectorCraft: under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images, `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and - scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline + scaling, and the Standard Vertical Roman Alignment and Tate-chu-yoko marks as `Tv` and `TY`), which the reading + app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation, which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text in the file), and Text: Outlines (`outlineText: true`) writes From a1a23c46e49e31e7f55db8175a6f4071b303fa52 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:05:53 -0800 Subject: [PATCH 66/72] Warp: the Warp effects bend an image's pixels with its frame A Warp effect on an image, or on a group that holds one, bent the image's frame but drew the pixels unbent, cut by the bent frame. The image now bends with the frame as a raster mesh warp, as an envelope bends it: after the last effect in the list, the image is cut into 10 x 10 cells of two triangles, each a clipped piece of the image under its own affine map, through every Warp it took and the Transform moves between them. Effects after a Warp reshape only the frame, and a placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. The image itself is unchanged, so editing or removing the warp redraws it. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when its clipping frame keeps its shape because the warp would pass the effects' anchor budget. - SVG export writes the image once and draws the pieces with <use> when a group holds pieces of one image clipped by triangles. PDF, EPS, .ai and PNG exports write the bent pieces. - Envelopes: a grayscale image's fill color now colorizes the pieces an envelope cuts. Before, the pieces drew the gray image. - Geometry effects: an image under any geometry effect keeps its fill color and its smoothing setting. Before, it drew the gray image, smoothed. Docs: Warp params text, docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/live.rs | 2 +- crates/doc/src/live/expand.rs | 47 ++- crates/effects/src/catalog.rs | 2 +- crates/effects/src/reshape.rs | 296 ++++++++++++++++++- crates/effects/src/tests.rs | 311 ++++++++++++++++++++ crates/effects/src/warp.rs | 18 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/envelope_distort.rs | 29 ++ crates/engine/src/tests/warp_images.rs | 293 ++++++++++++++++++ crates/svg/src/export/nodes.rs | 19 ++ docs/mcp.md | 10 + docs/target-app-parity.md | 1 + 12 files changed, 991 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/tests/warp_images.rs diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index cfba618bad..d1eb20ed21 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,7 +60,7 @@ pub use envelope::{ pub(crate) use expand::max_outset; pub use expand::{ Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, - expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, + expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live, }; #[cfg(test)] use mesh::grid_eval; diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..7c80866ee7 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -178,29 +178,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> { a.is_finite().then_some(a) } -/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into -/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners (with Preserve Shape Using -/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). -/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no -/// seams show. +/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`] +/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's +/// options say). fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); - // Two triangles a cell, three objects each; an image an inner envelope already cut into - // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image - // is left out. - if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + // An image an inner envelope already cut into pieces is cut again here, so the pieces count + // against [`MAX_GENERATED`]. Past it, the image is left out. + if !Generating::charge(mesh_warp_cost(k)) { let mut g = n.clone(); g.kind = NodeKind::Group { children: vec![], clip: false }; return g; } + mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency) +} + +/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two +/// triangles a cell, three objects each. +pub fn mesh_warp_cost(cells: usize) -> usize { + let k = cells.max(1); + k.saturating_mul(k).saturating_mul(6) +} + +/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into +/// `cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the +/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past +/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the +/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name, +/// transparency, opacity mask and appearance. +pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { + let k = cells.max(1); + // A grayscale image's fill color colorizes each piece; the group of pieces takes none. + let colorize = n.extras().colorize.clone(); let (pw, ph) = (im.width as f64, im.height as f64); let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); let mut pieces = Vec::with_capacity(2 * k * k); for j in 0..k { for i in 0..k { let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; - let d = s.map(|p| (w.f)(im.xf * p)); + let d = s.map(|p| f(im.xf * p)); for t in [[0, 1, 2], [0, 2, 3]] { let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); let Some(xf) = affine_between(st, dt) else { continue }; @@ -216,8 +233,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { .collect(); let shape = PathData::single(SubPath::polyline(&tri, true)); let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); - image.extras_mut().pixelated = !w.options.anti_alias; - if w.options.preserve_shape == PreserveShape::Transparency { + image.extras_mut().pixelated = pixelated; + image.extras_mut().colorize = colorize.clone(); + if masked { // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); @@ -234,6 +252,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { } let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } g } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a827bea0e2..7cbe2b2395 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..9d93f82a79 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -4,19 +4,22 @@ //! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths //! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect //! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a -//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects -//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without +//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp +//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur, +//! feather) stay on the result for the renderer and exporters. Type without //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). use std::collections::HashSet; +use std::rc::Rc; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use vectorcraft_doc::live::Hooks; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; @@ -548,7 +551,14 @@ fn as_art_opts( let mut art = match &n.kind { NodeKind::Image(im) => { let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - let image = Node::new(n.id, n.kind.clone()); + let mut image = Node::new(n.id, n.kind.clone()); + // Its pixels draw as the image's do: colorized by a grayscale image's fill color, and + // smoothed or not. + { + let mut e = image.extras_mut(); + e.colorize = n.extras().colorize.clone(); + e.pixelated = n.extras().pixelated; + } let clipped = || { let mut clip = Node::path(n.id, frame.clone(), Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -632,9 +642,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as /// its reference box and the art's seed (a random Transform moves the pieces together). Images and -/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry -/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The -/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path +/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images +/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them +/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path /// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns /// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one /// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]). @@ -705,8 +715,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let max = budget.at_least(used); // Read before the effects run: a path's own copies don't make it art of several objects. let copy_whole = copies_whole(art); + let mut warped = WarpedImages::new(); for e in fx { - let Some(bounds) = art.geometric_bounds() else { break }; + let Some(bounds) = warped.bounds(art) else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); @@ -739,10 +750,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget } let mut groups = vec![]; if follows { - group_marks(art, &mut vec![], &HashSet::new(), &mut groups); + group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups); + // The images earlier Warps bend are measured by their pieces only when a group's + // gradient needs the bounds. + if !groups.is_empty() + && let Some(images) = warped.when_cut(art) + { + groups.clear(); + group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups); + } } - // The ids of the paths that kept their shape (past the budget). + // The ids of the paths that kept their shape (past the budget), and their places in the + // order [`for_each_leaf`] visits the paths. let mut kept_ids = HashSet::new(); + let mut kept_leaves = HashSet::new(); + let mut leaf_at = 0usize; let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -757,6 +779,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget continue; } for_each_leaf(art, &mut |leaf| { + let at = leaf_at; + leaf_at += 1; let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -789,6 +813,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); + kept_leaves.insert(at); return; }; used = used.minus(before).plus(Budget::of(&out)); @@ -804,6 +829,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if boxes { rebase_leaves(art, &mut leaves.into_iter()); } + // Warp bends images too: each one's pixels follow the warp's map over the same box. The + // images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image + // whose clipping frame kept its shape (past the budget) keeps its pixels as they are. + if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id) + && crate::warp::bends(&e.id, ¶ms) + { + let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, ¶ms)); + warped.reserve(art); + mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped); + } if let Some((m, copies, options, _)) = transform && options.objects { @@ -816,14 +851,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; - let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; + let images = if groups.is_empty() { None } else { warped.when_cut(art) }; + let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1; if !groups.is_empty() { carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); } if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), warped.bounds(art), seed)); } } + cut_warped_images(art, &warped); whole } @@ -1445,17 +1482,25 @@ type GroupMark = (Vec<usize>, Option<Rect>, bool); /// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or /// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that -/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. -fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) { +/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images` +/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]). +fn group_marks( + n: &Node, + at: &mut Vec<usize>, + kept: &HashSet<NodeId>, + images: Option<&WarpedImages>, + out: &mut Vec<GroupMark>, +) -> (Option<Rect>, bool) { match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)), + NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false), NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => { let layer = matches!(n.kind, NodeKind::Layer { .. }); let skip = children.len().saturating_sub(n.editing_children().len()); let (mut bounds, mut held) = (None, false); for (i, c) in children.iter().enumerate() { at.push(i); - let (b, h) = group_marks(c, at, kept, out); + let (b, h) = group_marks(c, at, kept, images, out); if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } @@ -1590,6 +1635,225 @@ pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, ma copy_placed(n, m, copies, &mut room); } +/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number). +pub(crate) const WARP_IMAGE_CELLS: usize = 10; + +/// A Warp effect's point map, shared by the images it bends. +pub(crate) type WarpMap = Rc<dyn Fn(Point) -> Point>; + +/// One image a Warp effect bent: the image's own key, and each warp it took with the placement +/// the image had when it took it, in order. +type WarpedImage = (String, Vec<(WarpMap, Affine)>); + +/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's +/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform +/// effect makes keeps. The images are cut into pieces once, after the last effect +/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece, +/// and a later Warp or Transform moves the one set of pieces. +pub(crate) struct WarpedImages { + /// What this table's keys start with: no image key in the art starts with it + /// ([`WarpedImages::reserve`]). + prefix: String, + /// The prefix was checked against the art's image keys. + reserved: bool, + images: Vec<WarpedImage>, +} + +impl WarpedImages { + pub(crate) fn new() -> Self { + static TABLES: AtomicUsize = AtomicUsize::new(0); + Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] } + } + + /// Before the first image of `art` is marked: lengthen the prefix until no image key in `art` + /// starts with it, so no key from the document is read as one of this table's. + pub(crate) fn reserve(&mut self, art: &Node) { + if self.reserved { + return; + } + self.reserved = true; + let mut keys = vec![]; + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + keys.push(im.key.clone()); + } + }); + while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) { + self.prefix.push(':'); + } + } + + /// What this table's keys start with. + #[cfg(test)] + pub(crate) fn prefix(&self) -> &str { + &self.prefix + } + + /// The image that `key`, a key of this table, stands for. + fn get(&self, key: &str) -> Option<&WarpedImage> { + self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::<usize>().ok()?) + } + + /// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's + /// new key. + fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String { + let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![])); + warps.push((f.clone(), xf)); + self.images.push((own, warps)); + format!("{}{}", self.prefix, self.images.len() - 1) + } + + /// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that + /// this table lists? + fn fits(&self, n: &Node, max: usize) -> bool { + let mut count = 0usize; + n.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind + && self.get(&im.key).is_some() + { + count += 1; + } + }); + vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count()) + } + + /// The geometric bounds of `art` as it draws after the effects so far: each image this table + /// lists measured by the pieces the warps and moves it took would cut it into, when the art + /// has room for them ([`cut_warped_images`]). + pub(crate) fn bounds(&self, art: &Node) -> Option<Rect> { + if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) { + return art.geometric_bounds(); + } + // The pieces the images would be cut into now: each group of pieces is shown or hidden as + // its image is, and hidden objects are left out of a layer's bounds. + let mut probe = art.clone(); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// The geometric bounds of image `n`: when this table lists it, measured by the pieces the + /// warps and moves it took would cut it into. + fn image_bounds(&self, n: &Node) -> Option<Rect> { + let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() }; + if self.get(&im.key).is_none() { + return n.geometric_bounds(); + } + let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// This table when the art `art` has room for the pieces of the images it lists, so that a + /// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise. + fn when_cut(&self, art: &Node) -> Option<&Self> { + (!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self) + } +} + +/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers +/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of +/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the +/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take +/// the warp. +pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet<usize>, held: bool, at: &mut usize, warped: &mut WarpedImages) { + let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return }; + let mut held = held; + // A clip group's first member is its clipping art: when a path in it kept its shape, it and + // the members it clips keep theirs. + if *clip && let Some(first) = children.first() { + let start = *at; + held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p)); + } + for c in children.iter_mut() { + match &c.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1, + NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped), + NodeKind::Image(_) if !held => { + if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind { + im.key = warped.add(&im.key, im.xf, f); + } + } + _ => {} + } + } +} + +/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of +/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image +/// is, smoothed or not) through the warps it took and the moves between and after them +/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces +/// would pass it, no image is cut. An image whose placement has no inverse is not cut either. +fn cut_warped_images(n: &mut Node, warped: &WarpedImages) { + cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS); +} + +/// [`cut_warped_images`] with the art keeping to `max` nodes. +pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) { + if warped.images.is_empty() { + return; + } + let cut = warped.fits(n, max); + restore_warped_images(n, warped, cut); +} + +/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut +/// into pieces. +fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + let NodeKind::Image(im) = &c.kind else { + if c.children().is_some() { + restore_warped_images(Arc::make_mut(c), warped, cut); + } + continue; + }; + let Some((key, warps)) = warped.get(&im.key) else { continue }; + let mut image = (**c).clone(); + let NodeKind::Image(own) = &mut image.kind else { continue }; + own.key = key.clone(); + // The pieces start from the image as it was placed when it took its first warp. + let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() }); + let map = warped_map(warps, own.xf); + *c = Arc::new(match (first, map) { + (Some(first), Some(map)) if cut => { + let pixelated = image.extras().pixelated; + vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false) + } + _ => image, + }); + } +} + +/// The map that takes a point of an image placed by the first of `warps`' placements to where the +/// warps and the moves between them take it, for an image now placed by `placed`: each warp in +/// turn, then the move from the placement the image had when it took that warp to the one it had +/// at the next, or to `placed` after the last. `None` when a placement has no inverse. +fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option<Box<dyn Fn(Point) -> Point>> { + let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len()); + for (i, (f, at)) in warps.iter().enumerate() { + let det = at.determinant(); + if !det.is_finite() || det == 0.0 { + return None; + } + let next = warps.get(i + 1).map_or(placed, |(_, at)| *at); + let m = next * at.inverse(); + if !m.is_finite() { + return None; + } + steps.push((f.clone(), m)); + } + Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q)))) +} + +/// How many paths [`for_each_leaf`] visits in `n`. +fn leaf_count(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1, + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(), + _ => 0, + } +} + /// An object that a Transform effect places through its transform rather than reshapes: an image /// (pixel space to document) or a symbol instance (the symbol's art to document). fn is_placed(n: &Node) -> bool { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index adc518043e..e63b38a462 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -369,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() { assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001); } +/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`. +fn warp_test_image(id: u64) -> std::sync::Arc<Node> { + use vectorcraft_doc::{ImageObject, NodeId}; + let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im)))) +} + +/// How many images `art` holds, and the keys they have. +fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet<String>) { + let (mut n, mut keys) = (0, std::collections::BTreeSet::new()); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + n += 1; + keys.insert(im.key.clone()); + } + }); + (n, keys) +} + +#[test] +fn warped_images_stay_within_the_object_budget() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS); + let none = std::collections::HashSet::new(); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let warp = |art: &mut Node, warps: usize| { + let mut warped = crate::reshape::WarpedImages::new(); + for _ in 0..warps { + crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped); + } + warped + }; + // The art has room for one image's pieces only: neither image is cut, and each gets its own + // key back. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 1); + let max = art.count() + cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (2, k())); + // With room for both, each is cut into 200 pieces once, however many warps it took. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 3); + let max = art.count() + 2 * cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (400, k())); + // Images clipped by their frames: the second frame (the second path) kept its shape, so its + // image stays whole, and the first is cut. The third's frame is a group of two paths, the + // second of them kept, with an image after them, and its image sits in a group of its own: + // both stay whole too. + let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default())); + let clip_group = |id: u64, children: Vec<Arc<Node>>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true })); + let nested = clip_group( + 4, + vec![ + Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])), + Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])), + ], + ); + let mut art = + Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]); + let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new()); + crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped); + assert_eq!(at, 4); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (203, k())); +} + +/// An image key from the document that looks like a key of the table is not read as one, and a +/// hidden image stays out of the bounds the later effects take, as it does before it is warped. +#[test] +fn warped_images_keep_document_keys_and_hidden_images_apart() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let none = std::collections::HashSet::new(); + let mut warped = crate::reshape::WarpedImages::new(); + let lookalike = format!("{}0", warped.prefix()); + let mut b = (*warp_test_image(3)).clone(); + if let NodeKind::Image(im) = &mut b.kind { + im.key = lookalike.clone(); + } + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike]))); + // A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured. + let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default())); + let mut hidden = (*warp_test_image(5)).clone(); + hidden.visible = false; + let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = vec![square, Arc::new(hidden)]; + } + let before = layer.geometric_bounds(); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&layer); + crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped); + assert_eq!(warped.bounds(&layer), before); + // A shown image is measured by the pieces it is cut into. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + let measured = warped.bounds(&art); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(measured, art.geometric_bounds()); + assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}"); + // An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed + // with no area is not. + for (scale, pieces) in [(1e-7, 200), (0.0, 1)] { + let mut image = (*warp_test_image(2)).clone(); + if let NodeKind::Image(im) = &mut image.kind { + im.xf = Affine::scale(scale); + } + let mut art = Node::group(NodeId(1), vec![Arc::new(image)]); + let mut warped = crate::reshape::WarpedImages::new(); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art).0, pieces, "{scale}"); + } +} + +/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its +/// pixels as they are; within the budget, both bend. +#[test] +fn an_image_whose_frame_passes_the_budget_stays_whole() { + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let clipped = || { + let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true }) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None); + assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4)); + assert_eq!(warp_test_images(&art), (1, k())); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None); + assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4)); + assert_eq!(warp_test_images(&art), (200, k())); +} + +/// The first image under `n` (the first piece of a cut image). +fn first_image(n: &Node) -> Option<vectorcraft_doc::ImageObject> { + let mut first = None; + n.walk(&mut |c| { + if first.is_none() + && let NodeKind::Image(im) = &c.kind + { + first = Some((**im).clone()); + } + }); + first +} + +#[test] +fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() { + let image = |effects: Vec<Effect>| { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = effects; + n + }; + // Each piece's clipping path, and the image's frame (the outer clip group's clipping path). + let clips = |art: &Node| { + let (mut pieces, mut frame) = (vec![], None); + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let Some(path) = children.first().and_then(|p| p.path_data()) + { + if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 { + pieces.push(path.anchor_count()); + } else if frame.is_none() { + frame = Some(path.anchor_count()); + } + } + }); + (pieces, frame) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + for after in [ + fx("distort.roughen", json!({"size": 5, "detail": 10})), + fx("stylize.roundCorners", json!({"radius": 5})), + fx("path.offsetPath", json!({"offset": -2})), + ] { + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap(); + let (pieces, frame) = clips(&art); + assert_eq!(pieces.len(), 200, "{}", after.id); + assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id); + assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id); + } + // Two warps cut the image once, through both maps; a Transform after them moves the pieces. + let flag = fx("warp.flag", json!({"bend": 30, "version": 1})); + let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0})); + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap(); + assert_eq!(warp_test_images(&moved).0, 200); + let box1 = Rect::new(0.0, 0.0, 40.0, 40.0); + let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params)); + // The flag bends the box the arc leaves, which the arc's frame measures. + let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap(); + let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params)); + // The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4). + for (art, by) in [(&art, 0.0), (&moved, 100.0)] { + let piece = first_image(art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0); + assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p); + } + } +} + +/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it +/// bends the frame. +#[test] +fn an_images_pixels_take_the_map_their_warps_version_names() { + for version in [json!(null), json!(0), json!(1)] { + let mut params = json!({"bend": 50}); + if !version.is_null() { + params["version"] = version.clone(); + } + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("warp.arc", params.clone())]; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", ¶ms)); + let piece = first_image(&art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}"); + } + // The frame has an anchor where the map takes each corner. + let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap(); + for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] { + let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6); + assert!(near, "{version}: {corner:?}"); + } + } +} + +/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's +/// bounds to where its image's pieces draw them. +#[test] +fn a_nested_groups_gradient_follows_its_warped_image() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed))); + let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default()); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]); + g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1}))); + let art = crate::group::evaluate_container(None, &g).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None); + art.walk(&mut |c| { + if c.id == NodeId(5) && to.is_none() { + to = c.geometric_bounds(); + } + }); + let to = to.unwrap(); + assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}"); + let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()); + let mut checked = 0; + art.walk(&mut |c| { + if c.id != NodeId(5) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let got = gp.geom.unwrap(); + assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}"); + checked += 1; + } + }); + assert!(checked > 0); +} + +/// An image under a geometry effect other than a Warp draws as the image does: colorized by +/// a grayscale image's fill color, and smoothed or not. +#[test] +fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))]; + n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + n.extras_mut().pixelated = true; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let mut seen = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Image(_)) { + seen.push((c.extras().colorize.clone(), c.extras().pixelated)); + } + }); + assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]); +} + /// What `effects` make of `path` within `budget`. fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData { apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget) diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index f607abacdf..4b117d4142 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -20,25 +20,30 @@ pub fn map_version(p: &Value) -> u32 { /// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { + let piece = b.width().hypot(b.height()) / 24.0; + map_nonlinear_within(path, piece, max, warp_map(b, style, p)) +} + +/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each +/// point of a path to. +pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box<dyn Fn(Point) -> Point> { let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0; let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); - let piece = b.width().hypot(b.height()) / 24.0; if uses_net(map_version(p)) { let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical); let (w, h) = (b.width(), b.height()); // A box with no width or no height takes the middle of the patch along that side. - let f = |q: Point| { + return Box::new(move |q: Point| { let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 }); b.origin() + net.at(u, v).to_vec2() - }; - return map_nonlinear_within(path, piece, max, f); + }); } let c = b.center(); let hw = (b.width() / 2.0).max(1e-9); let hh = (b.height() / 2.0).max(1e-9); - let f = |q: Point| { + Box::new(move |q: Point| { let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); let (x2, y2) = if vertical { let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x); @@ -47,8 +52,7 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - warp_point(style, bend, dh, dv, hh / hw, x, y) }; Point::new(c.x + x2 * hw, c.y + y2 * hh) - }; - map_nonlinear_within(path, piece, max, f) + }) } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index abf65d834a..de10dc4e17 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -205,6 +205,7 @@ mod typescale; mod typeundo; mod units; mod variables; +mod warp_images; mod webexport; mod widthpoints; mod widthprofiles; diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 2dbef41238..435d918552 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() { assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels"); } +#[test] +fn a_colorized_image_keeps_its_color_in_an_envelope() { + let mut s = session(); + let img = image::GrayImage::from_pixel(4, 4, image::Luma([0])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); + let im = + vectorcraft_doc::ImageObject { key: "gray".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [im.0]})).unwrap(); + sel(&mut s, &[im]); + arch(&mut s, 80.0, json!({})); + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + // The black pixels draw in the fill color, in the bent shape. + let mid = img.pixel(120, 97); + assert!(mid[0] > 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}"); + let inside = img.pixel(120, 120); + assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}"); +} + /// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one. fn two_color_image(s: &mut Session) -> NodeId { let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs new file mode 100644 index 0000000000..47175fc866 --- /dev/null +++ b/crates/engine/src/tests/warp_images.rs @@ -0,0 +1,293 @@ +//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports. + +use serde_json::{Value, json}; +use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, Point, Rect}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// The box of the image [`quarters`] places. +const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0); + +/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top +/// left, green top right, blue bottom left, yellow bottom right. +fn quarter(u: f64, v: f64) -> [u8; 3] { + match (u < 0.5, v < 0.5) { + (true, true) => [255, 0, 0], + (false, true) => [0, 255, 0], + (true, false) => [0, 0, 255], + (false, false) => [255, 255, 0], + } +} + +/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]). +fn quarters(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0); + image::Rgba([r, g, b, 255]) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "quarters".into(), + width: 40, + height: 40, + xf, + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`]. +fn arced(bend: f64, u: f64, v: f64) -> Point { + let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false); + FRAME.origin() + net.at(u, v).to_vec2() +} + +/// Points of the image's box: near each quarter's middle and on both sides of the line between +/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color. +const SAMPLES: [(f64, f64); 10] = + [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)]; + +/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white. +fn drawn(s: &Session) -> vectorcraft_render::Rendered { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true) +} + +/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v). +fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> { + SAMPLES + .iter() + .filter_map(|&(u, v)| { + let p = at(u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + let want = quarter(u, v); + let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40); + (!near).then_some((u, v, px)) + }) + .collect() +} + +/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the +/// Bézier patch [`arced`] follows. +fn arc_image(s: &mut Session) -> NodeId { + let id = quarters(s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let e = &n.appearance.effects[0]; + assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params); + id +} + +fn decoded(v: &Value) -> Vec<u8> { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +#[test] +fn an_images_pixels_bend_with_effect_warp() { + let mut s = session(); + let id = arc_image(&mut s); + // The arc lifts the middle of the image: the line between the quarters rises there. + assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6)); + let bent = drawn(&s); + assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it"); + // Drawn as pieces of the one image, each placed by the warp. + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap(); + let mut pieces = 0; + art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize); + assert!(pieces >= 100, "{pieces} pieces"); + // Removing the warp draws the image as it was, in one undo step. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height()); + assert_eq!(wrong(&drawn(&s), &plain), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back"); +} + +/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image +/// bent. +fn opens_bent(name: &str, out: &Value) { + let mut back = session(); + back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap(); + assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}"); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_svg() { + let mut s = session(); + arc_image(&mut s); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let text = String::from_utf8(decoded(&svg)).unwrap(); + assert_eq!(text.matches("<image").count(), 1, "the pieces share the image's pixels"); + opens_bent("warped.svg", &svg); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_pdf() { + let mut s = session(); + arc_image(&mut s); + opens_bent("warped.pdf", &s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap()); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_ai() { + let mut s = session(); + arc_image(&mut s); + opens_bent("warped.ai", &s.execute("file.saveCopy", &json!({"format": "ai"})).unwrap()); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_eps() { + let mut s = session(); + arc_image(&mut s); + opens_bent("warped.eps", &s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap()); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_png() { + let mut s = session(); + arc_image(&mut s); + let out = s.execute("document.export", &json!({"format": "png"})).unwrap(); + let png = image::load_from_memory(&decoded(&out)).unwrap().to_rgba8(); + // The artboard starts at (0, 0) and the export draws one pixel a point. + assert_eq!((png.width(), png.height()), (800, 600)); + for &(u, v) in &SAMPLES { + let p = arced(50.0, u, v); + let px = png.get_pixel(p.x.floor() as u32, p.y.floor() as u32).0; + let want = quarter(u, v); + assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}"); + } +} + +/// A group the user made of two clip groups of the same image, each clipped by a rectangle, writes +/// the image once for each: only the pieces of a warp share one. +#[test] +fn svg_writes_an_image_clipped_twice_in_a_users_group_twice() { + let mut s = session(); + let im = quarters(&mut s); + s.edit("Clip twice", |d, _| { + let image = d.node(im).cloned().unwrap(); + let mut clipped = vec![]; + for x in [FRAME.x0, FRAME.x0 + 40.0] { + let mut clip = + Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(x, FRAME.y0, x + 30.0, FRAME.y0 + 30.0)), Default::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let image = Node { id: d.alloc_id(), ..image.clone() }; + let children = vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)]; + clipped.push(std::sync::Arc::new(Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }))); + } + let group = Node::group(d.alloc_id(), clipped); + d.remove(im)?; + d.insert(d.default_layer(), 0, group)?; + Ok(()) + }) + .unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let text = String::from_utf8(decoded(&svg)).unwrap(); + assert_eq!(text.matches("<image").count(), 2, "{text}"); +} + +/// Expand Appearance of a warped image gives objects that draw as the canvas did, with ids of +/// their own, in one undo step. +#[test] +fn expand_appearance_of_a_warped_image_draws_as_the_canvas_did() { + let mut s = session(); + let id = arc_image(&mut s); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let d = &s.doc().unwrap().doc; + let mut ids = std::collections::HashSet::new(); + let mut images = 0; + for layer in &d.layers { + layer.walk(&mut |c| { + assert!(ids.insert(c.id), "{:?} twice", c.id); + images += matches!(c.kind, NodeKind::Image(_)) as usize; + assert!(c.appearance.effects.iter().all(|e| !e.id.starts_with("warp."))); + }); + } + assert!(images >= 100, "{images} pieces"); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} + +/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized +/// red by its fill color: red above, nothing below. +fn red_on_clear(s: &mut Session) -> NodeId { + let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let id = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf, + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + id +} + +#[test] +fn a_colorized_image_keeps_its_color_when_warped() { + let mut s = session(); + red_on_clear(&mut s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let img = drawn(&s); + let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]); + for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] { + let p = arced(50.0, u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}"); + } +} + +#[test] +fn a_warp_on_a_group_bends_the_image_in_it() { + let mut s = session(); + let im = quarters(&mut s); + // A group of the image and a line below it as wide as the image: the group's box is the image's. + let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap(); + let line = NodeId(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 3ab4003d7d..c5a98aebf1 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -121,6 +121,21 @@ impl Reuse { } } +/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]): +/// more than one, each a clip group of a triangle and one and the same image? +fn image_pieces(children: &[std::sync::Arc<Node>]) -> bool { + let mut first: Option<(&str, u32, u32)> = None; + children.len() > 1 + && children.iter().all(|c| { + let NodeKind::Group { children, clip: true } = &c.kind else { return false }; + let [clip, im] = children.as_slice() else { return false }; + let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false }; + let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3; + let image = (im.key.as_str(), im.width, im.height); + triangle && *first.get_or_insert(image) == image + }) +} + /// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas /// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement /// and the art of unlinked opacity masks. @@ -678,6 +693,10 @@ impl Writer<'_> { match &n.kind { NodeKind::Layer { template: true, .. } => {} NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + // The pieces a Warp effect cuts an image into share its pixels, as an envelope's do. + if image_pieces(children) { + self.share_images(n); + } let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); self.line(&format!("<g{id}{a}>")); diff --git a/docs/mcp.md b/docs/mcp.md index 58a8006d03..9db2c0e1ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3332,6 +3332,16 @@ that shape, and the effects before it are left out. Its Contents row stands for vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the image. Use Preview Bounds measures an image by its frame. +A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its +frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of +the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame, +a second Warp bends the same pieces, and a Transform effect moves them. A placed gradient on a group that holds the +image follows the group's bounds as the pieces draw them. The pieces keep the +image's fill color when it colorizes a grayscale image, and so do the pieces an envelope cuts. Under any geometry +effect, an image keeps that color and is drawn with or without smoothing as the image is. SVG writes the image once and +draws the pieces with `<use>`; PDF, EPS, `.ai` and PNG write the bent pieces. Removing the warp shows the image as it +was. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when +its clipping frame keeps its shape because the warp would pass the effects' anchor budget. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b7c8e51357..98f852cc6f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -591,6 +591,7 @@ since 2026-10-01. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. +- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. From 98d6cedf2530ed31dca722052ac488fcdec9a0fd Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 01:27:01 -0800 Subject: [PATCH 67/72] Tests: Direct Selection marquee and hover and Group Selection pass through raster effects Six tests cover the Direct Selection marquee (anchors and crossed segments), its pointer and anchor highlight, and the Group Selection click, repeated click, marquee (default and E for enclosed objects) and hover over a drop shadow: they reach the object under the shadow or nothing. The selection passes-through line in docs/target-app-parity.md names these gestures. --- crates/engine/src/tests/select_effects.rs | 157 ++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs index b30d0ef70e..d2160fba34 100644 --- a/crates/engine/src/tests/select_effects.rs +++ b/crates/engine/src/tests/select_effects.rs @@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { // From above B, over B and the shadow: B only. assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); } + +/// The selection after a drag with `tool` from `from` to `to`. +fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection { + press(s, tool, from, to, Mods::default()); + s.doc().unwrap().selection.clone() +} + +/// The point of anchor `(si, ai)` of path `id`. +fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point { + let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap(); + path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap() +} + +/// The anchors a marquee or a click picked on `id`, as points. +fn picked_points(s: &Session, id: NodeId) -> Vec<Point> { + let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect(); + refs.into_iter().map(|r| anchor_point(s, id, r)).collect() +} + +/// The anchors the tool marks as hovered. +fn anchor_marks(s: &mut Session) -> Vec<Point> { + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None }) + .collect() +} + +fn hover(s: &mut Session, x: f64, y: f64) -> Cursor { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap(); + s.cursor(Point::new(x, y), Mods::default(), v) +} + +#[test] +fn a_direct_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + let (a_before, b_before) = (bounds(&s, a), bounds(&s, b)); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + // Over the shadow alone: no object, no anchor, no segment. + let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B, and nothing of A. + let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // Across B's top edge under the shadow, with neither of its anchors inside: that segment. + run(&mut s, "select.none", json!({})); + assert_ne!(pixel(&s, 112, 25), BLUE); + let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before)); +} + +#[test] +fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + // B's upper left corner lies under the shadow. + assert_ne!(pixel(&s, 101, 21), BLUE); + run(&mut s, "select.none", json!({})); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor); + assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]); + // A press there picks that anchor of B. + assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); +} + +#[test] +fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); + assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]); +} + +#[test] +fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() { + let mut s = session(); + // B and C in one group, A and D in another above it, and A's shadow over B. + let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [b.0, c.0]})); + let gb = id_of(&run(&mut s, "object.group", json!({}))); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, d.0]})); + let ga = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, a, 40.0); + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + let click = |s: &mut Session, x, y| { + let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default()); + sel.sort_by_key(|n| n.0); + sel + }; + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + let mut both = vec![b, gb]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 110.0, 50.0), both); + // With A selected, a click on its shadow over the canvas does not add A's group. + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); + // On A itself the second click adds it. + assert_eq!(click(&mut s, 50.0, 50.0), [a]); + let mut both = vec![a, ga]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 50.0, 50.0), both); +} + +#[test] +fn a_group_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B. + let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // With E, a marquee around B that crosses A's shadow selects B whole, and not A. + run(&mut s, "select.none", json!({})); + let v = ViewInfo::default(); + s.select_tool("groupSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap(); + s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [b]); + assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}"); +} + +#[test] +fn group_selection_hover_over_a_drop_shadow_marks_nothing() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + s.select_tool("groupSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..db12554849 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -600,7 +600,7 @@ since 2026-10-01. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. -- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. 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 68/72] 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 69/72] 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 70/72] 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. | From 842f1f1b68e5411e35c51c8089ce146125d9f0f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 05:01:14 -0800 Subject: [PATCH 71/72] EPS import: CIDFonts only named, over a predefined CJK CMap, set their characters as live type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type in a font found by name with a predefined non-Unicode CMap, such as `/Ryumin-Light-83pv-RKSJ-H findfont` or a `composefont` with `GBK-EUC-H`, opened as Latin-1 letters (`ú{A` for 日本A) in a font whose style carried the CMap name (`Light-83pv-RKSJ-H`). Only Unicode CMaps were split off the font name, and `composefont` failed with `undefinedresource` for the others. - The predefined CMap decoder used by legacy `.ai` type moves to `import/predefined_cmap.rs`, and EPS import decodes these CMaps' codes with it (Shift-JIS, EUC, GBK, Big Five, UHC and the other public CMaps). - A font name splits at a predefined CMap unless an installed face has the whole name. A CMap that can't be read gives a warning. - Type over a vertical CMap (`-V`) is set as vertical type, and each code moves the point one em down the column, the default `DW2` displacement. - Every show operator sets decoded type with the same spacing and shaping as other fonts found by name. - Decoding a string counts against the import's memory budget. --- crates/engine/tests/import_fuzz.rs | 5 +- crates/eps/src/import.rs | 1 + crates/eps/src/import/ai/legacy_text.rs | 278 +----------------- crates/eps/src/import/cid.rs | 32 +- crates/eps/src/import/interp.rs | 4 + crates/eps/src/import/predefined_cmap.rs | 358 +++++++++++++++++++++++ crates/eps/src/import/tests_cid.rs | 165 +++++++++++ crates/eps/src/import/text.rs | 261 ++++++++++++----- 8 files changed, 729 insertions(+), 375 deletions(-) create mode 100644 crates/eps/src/import/predefined_cmap.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 67bb04c33b..ac98db8e9c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2682,7 +2682,10 @@ fn arb_ps_token() -> impl Strategy<Value = String> { // Local VM restored: dictionary changes undone, saves restored twice. "/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store", // Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`). - "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit" + "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit", + // CID-keyed fonts found by name over predefined CMaps, horizontal and vertical. + "/Ryumin-Light-90ms-RKSJ-H", "/Ryumin-Light-EUC-V", "/Ryumin-Light-H", "/GBT-EUC-H", "/KSCms-UHC-V", "/UniJIS-UTF16-V", + "[/Ryumin-Light]", "composefont", "<93fa967b8540fd0d41>", "<c7d1a4a4467c>" ]) .prop_map(str::to_string), ] diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 0a5799ddf9..dc335bf1a1 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -42,6 +42,7 @@ mod interp; mod lex; mod native; mod obj; +mod predefined_cmap; mod preview; mod shading; mod text; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index a1b24662ec..6cdbb96747 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -14,12 +14,12 @@ use std::sync::Arc; -use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; use vectorcraft_geom::AnchorKind; +use super::super::super::predefined_cmap::{self, CMapCodes, composite_font}; use super::super::obj::V; use super::art::path_data; use super::{FLIP, GState, HashSet, Kind, Node, NodeId, Reader}; @@ -284,259 +284,15 @@ impl TypeState { /// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read. fn cmap_codes(&mut self, cmap: &str) -> Option<Arc<CMapCodes>> { - if let Some(form) = unicode_cmap(cmap) { - return Some(Arc::new(CMapCodes::Unicode(form))); - } if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) { return codes.clone(); } - let codes = cid_codes(cmap).map(Arc::new); + let codes = predefined_cmap::codes(cmap).map(Arc::new); self.cmaps.push((cmap.to_string(), codes.clone())); codes } } -/// How a composite font's strings decode. -#[derive(Debug)] -enum CMapCodes { - /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; - /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's - /// code space ([`code_space`]). - Cids { codes: Box<CMap>, unicode: Box<CMap>, high_bit: bool, space: Option<CodeSpace> }, - /// A predefined Unicode CMap: the codes are the characters. - Unicode(Unicode), -} - -/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the -/// code's length in bytes. -type CodeSpace = &'static [(&'static [u8], &'static [u8])]; - -/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. -fn control(b: u8) -> Option<char> { - match b { - b'\r' => Some('\n'), - b'\t' => Some('\t'), - _ => None, - } -} - -impl CMapCodes { - /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a - /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a - /// UTF-16 or UTF-32 string). - fn decode(&self, bytes: &[u8]) -> String { - let (codes, unicode, high_bit, space) = match self { - Self::Unicode(form) => { - let unit = match form { - Unicode::Utf16 => 2, - Unicode::Utf8 => 1, - Unicode::Utf32 => 4, - }; - let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); - let mut text = unicode_text(whole, *form).replace('\r', "\n"); - text.extend(rest.last().copied().and_then(control)); - return text; - } - Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), - }; - let euc: Vec<u8>; - let bytes = if high_bit { - euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); - euc.as_slice() - } else { - bytes - }; - let mut out = String::new(); - let mut at = 0; - while let Some(&first) = bytes.get(at) { - // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every - // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space - // has no one-byte codes. - if let Some(control) = control(first) { - out.push(control); - at += 1; - continue; - } - let rest = bytes.get(at..).unwrap_or_default(); - let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b))); - let found = match space { - // The code is as long as the code space range it falls in; a byte that starts no - // code in the code space is skipped. - Some(space) => { - let Some(len) = code_len(space, rest) else { - at += 1; - continue; - }; - code(len).map(|c| (len, codes.lookup_cid_code(c, len))) - } - None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), - }; - let Some((len, cid)) = found else { - at += 1; - continue; - }; - at += usize::from(len); - // A code the CMap leaves out is dropped. - let Some(cid) = cid else { continue }; - match unicode.lookup_bf_string(cid) { - // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in - // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. - Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)), - Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c), - Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), - _ => {} - } - } - out - } -} - -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - -/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, -/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` -/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: <https://github.com/adobe-type-tools/cmap-resources>). -fn unicode_cmap(name: &str) -> Option<Unicode> { - let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; - let rest = rest.strip_suffix("-HW").unwrap_or(rest); - let (collection, form) = rest.rsplit_once('-')?; - let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; - if !collections.contains(&collection) { - return None; - } - match form { - "UCS2" | "UTF16" => Some(Unicode::Utf16), - "UTF8" => Some(Unicode::Utf8), - "UTF32" => Some(Unicode::Utf32), - _ => None, - } -} - -/// The characters string `s` encodes in Unicode form `form` (invalid codes left out). -fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - -/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose -/// codes are CIDs of no known character collection. -fn predefined(name: &str) -> Option<CMapName<'_>> { - match CMapName::from_bytes(name.as_bytes()) { - CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, - name => Some(name), - } -} - -/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` -/// (<https://github.com/adobe-type-tools/cmap-resources>): the nearest carried CMap with the same -/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC -/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space -/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. -fn stand_in(stem: &str) -> Option<Option<(&'static str, bool)>> { - Some(match stem { - "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), - "78-EUC" => Some(("EUC-H", false)), - "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), - "GB" => Some(("GB-EUC-H", true)), - "CNS1" => Some(("CNS-EUC-H", true)), - "KSC" => Some(("KSC-EUC-H", true)), - "B5" => Some(("ETen-B5-H", false)), - "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), - "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, - _ => return None, - }) -} - -/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A -/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two -/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with -/// its [`stand_in`]. -fn cid_codes(cmap: &str) -> Option<CMapCodes> { - let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); - let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); - let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); - let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { - let (name, high_bit) = stand_in(stem?).flatten()?; - Some((read(name)?, high_bit)) - })?; - let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; - let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; - // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. - let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; - Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) -} - -/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it -/// (<https://github.com/adobe-type-tools/cmap-resources>); a CMap's vertical form has its -/// horizontal form's. `None` for a CMap not listed here. -fn code_space(cmap: &str) -> Option<CodeSpace> { - const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; - const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; - const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; - const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; - const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; - const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; - const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; - const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; - const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; - const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; - const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; - const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; - const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; - const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; - let stem = match cmap { - "H" | "V" => "", - _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, - }; - let stem = stem.strip_suffix("-HW").unwrap_or(stem); - Some(match stem { - "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, - _ if stem.ends_with("RKSJ") => RKSJ, - "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, - "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, - "GBpc-EUC" | "GBTpc-EUC" => GB_PC, - "GBK-EUC" | "GBKp-EUC" => GBK, - "GBK2K" => GBK2K, - "B5pc" => B5_PC, - "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, - "HKscs-B5" | "ETHK-B5" => HKSCS, - "HKdlb-B5" => HKDLB, - "HKgccs-B5" => HKGCCS, - "CNS-EUC" => CNS_EUC, - "KSCpc-EUC" => KSC_PC, - "KSCms-UHC" => UHC, - _ => return None, - }) -} - -/// The length of the code string `bytes` starts with: the length of the code space range whose -/// lowest and highest codes hold each of its bytes between them. `None` when no range does. -fn code_len(space: CodeSpace, bytes: &[u8]) -> Option<u8> { - space - .iter() - .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) - .map(|(lo, _)| lo.len()) - .min() - .and_then(|len| u8::try_from(len).ok()) -} - /// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`. fn encoding_pairs(table: &mut [Option<char>], vals: &[V]) { let mut code: Option<usize> = None; @@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option<char>], vals: &[V]) { } } -/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a -/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries -/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical -/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an -/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`. -fn composite_font(name: &str) -> Option<(&str, &str)> { - if !(name.ends_with("-H") || name.ends_with("-V")) { - return None; - } - // The longest CMap name first: `83pv-RKSJ-H` before `H`. - // No predefined CMap name is longer than 32 bytes. - for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { - let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; - if matches!(cmap, "Identity-H" | "Identity-V") { - return None; - } - let encoding_part = - base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); - if matches!(cmap, "H" | "V") && encoding_part { - return None; - } - let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); - let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); - if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() { - return Some((base, cmap)); - } - } - None -} - /// An operator's `N` operands, the last `N` values before it, when each is a finite number. fn nums<const N: usize>(vals: &[V]) -> Option<[f64; N]> { let operands = vals.get(vals.len().checked_sub(N)?..)?; diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index e2ad169f79..a54c11ae2b 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -18,6 +18,7 @@ use vectorcraft_geom::BezPath; use super::interp::{Interp, matrix_obj}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; +use super::predefined_cmap::Unicode; /// Most codespace ranges, character and notdef mappings a CMap may hold. const MAX_RANGES: usize = 1 << 16; @@ -210,16 +211,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option<u64> { (n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b))) } -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -pub(super) enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - /// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's /// public CMap resources whose name says its codes are Unicode: `Uni` and a character /// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), @@ -246,18 +237,6 @@ pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> { Some((form, vertical)) } -/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out). -pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - /// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for /// `Identity-V`. pub(super) fn identity(name: &str, vertical: bool) -> Obj { @@ -417,8 +396,13 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - match unicode_cmap(&text) { - Some((_, vertical)) => { + // Another predefined CMap the file doesn't define, without its mappings: its codes are + // read by its name when type is set (see `text`). + let predefined = unicode_cmap(&text) + .map(|(_, vertical)| vertical) + .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text))); + match predefined { + Some(vertical) => { let mut d = Dict::new(); d.insert(Key::name("CMapName"), Obj::name(&text)); d.insert(Key::name("CMapType"), Obj::Int(1)); diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 992a8bbcea..58708bb97e 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -92,6 +92,9 @@ pub(crate) struct Interp<'a> { pub cid_subrs: super::cid::CidSubrs, /// The font number `usefont` gave the CMap mappings that follow. pub cmap_font: i64, + /// The predefined CMaps type was set in so far, by name, with how their strings decode + /// (`None` when one can't be read; see `text`). + pub predefined_cmaps: Vec<(Rc<str>, Option<Rc<super::predefined_cmap::CMapCodes>>)>, pub g: GState, pub saved: Vec<GState>, pub out: Out, @@ -232,6 +235,7 @@ impl<'a> Interp<'a> { cff: vec![], cid_subrs: vec![], cmap_font: 0, + predefined_cmaps: vec![], type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs new file mode 100644 index 0000000000..1a9b3b95be --- /dev/null +++ b/crates/eps/src/import/predefined_cmap.rs @@ -0,0 +1,358 @@ +//! Strings in fonts over Adobe's predefined CMaps (<https://github.com/adobe-type-tools/cmap-resources>) +//! read as characters: the codes of a predefined Unicode CMap are the characters; those of the +//! others map to CIDs of a character collection, whose CID → Unicode CMap gives the characters. +//! Used by legacy `.ai` type in composite fonts and by type in CID-keyed fonts found by name. + +use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; + +/// How a composite font's strings decode. +#[derive(Debug)] +pub(super) enum CMapCodes { + /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; + /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's + /// code space ([`code_space`]). + Cids { codes: Box<CMap>, unicode: Box<CMap>, high_bit: bool, space: Option<CodeSpace> }, + /// A predefined Unicode CMap: the codes are the characters. + Unicode(Unicode), +} + +/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the +/// code's length in bytes. +type CodeSpace = &'static [(&'static [u8], &'static [u8])]; + +/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. +fn control(b: u8) -> Option<char> { + match b { + b'\r' => Some('\n'), + b'\t' => Some('\t'), + _ => None, + } +} + +impl CMapCodes { + /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a + /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a + /// UTF-16 or UTF-32 string). + pub(super) fn decode(&self, bytes: &[u8]) -> String { + let mut text = String::new(); + self.each_code(bytes, |_, chars| text.extend(chars)); + text + } + + /// The codes of string `bytes`, each with its value and the characters it stands for (as + /// [`Self::decode`] reads them; none for a code the CMaps don't map, or for a byte that starts + /// no code, which counts as a code of its own). + pub(super) fn codes(&self, bytes: &[u8]) -> Decoded { + let mut out = Decoded::default(); + self.each_code(bytes, |code, chars| out.push(code, chars)); + out + } + + /// Calls `emit` with each code of string `bytes` in turn ([`Self::codes`]): its value and the + /// characters it stands for. + fn each_code(&self, bytes: &[u8], mut emit: impl FnMut(u32, &mut dyn Iterator<Item = char>)) { + let value = |b: &[u8]| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)); + let (codes, unicode, high_bit, space) = match self { + Self::Unicode(form) => { + let unit = match form { + Unicode::Utf16 => 2, + Unicode::Utf8 => 1, + Unicode::Utf32 => 4, + }; + let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); + unicode_codes(whole, *form, |code, c| emit(value(code), &mut c.map(|c| if c == '\r' { '\n' } else { c }).into_iter())); + if let Some(&last) = rest.last() { + emit(value(rest), &mut control(last).into_iter()); + } + return; + } + Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), + }; + // A code's value is that of the string's bytes, also where they're read in their EUC form. + let original = bytes; + let value_at = |at: usize, len: usize| original.get(at..at + len).map_or(0, value); + let euc: Vec<u8>; + let bytes = if high_bit { + euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); + euc.as_slice() + } else { + bytes + }; + let mut at = 0; + while let Some(&first) = bytes.get(at) { + // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every + // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space + // has no one-byte codes. + if let Some(control) = control(first) { + emit(value_at(at, 1), &mut Some(control).into_iter()); + at += 1; + continue; + } + let rest = bytes.get(at..).unwrap_or_default(); + let code = |len: u8| rest.get(..usize::from(len)).map(value); + let found = match space { + // The code is as long as the code space range it falls in; a byte that starts no + // code in the code space is skipped. + Some(space) => code_len(space, rest).and_then(|len| code(len).map(|c| (len, codes.lookup_cid_code(c, len)))), + None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), + }; + let Some((len, cid)) = found else { + emit(value_at(at, 1), &mut None.into_iter()); + at += 1; + continue; + }; + let value = value_at(at, usize::from(len)); + at += usize::from(len); + // A code the CMap leaves out stands for no character. + match cid.and_then(|cid| unicode.lookup_bf_string(cid)) { + // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in + // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. + Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => { + emit(value, &mut Some(char::from(first)).into_iter()) + } + Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => emit(value, &mut Some(c).into_iter()), + Some(BfString::String(s)) => emit(value, &mut s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), + _ => emit(value, &mut None.into_iter()), + } + } + } +} + +/// A string's characters, and its codes: each code's value and how many of the characters it +/// stands for, in order. +#[derive(Debug, Default)] +pub(super) struct Decoded { + pub text: String, + pub codes: Vec<(u32, usize)>, +} + +impl Decoded { + /// Code `code`, standing for characters `chars`. + pub(super) fn push(&mut self, code: u32, chars: impl IntoIterator<Item = char>) { + let before = self.text.len(); + self.text.extend(chars); + let n = self.text.get(before..).map_or(0, |added| added.chars().count()); + self.codes.push((code, n)); + } +} + +/// How strings in a font over predefined CMap `cmap` decode; `None` when the CMap can't be read. +pub(super) fn codes(cmap: &str) -> Option<CMapCodes> { + match unicode_cmap(cmap) { + Some(form) => Some(CMapCodes::Unicode(form)), + None => cid_codes(cmap), + } +} + +/// How a predefined Unicode CMap's codes encode their characters. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum Unicode { + /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. + Utf16, + Utf8, + /// Four bytes, the high byte first. + Utf32, +} + +/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, +/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` +/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: <https://github.com/adobe-type-tools/cmap-resources>). +fn unicode_cmap(name: &str) -> Option<Unicode> { + let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; + let rest = rest.strip_suffix("-HW").unwrap_or(rest); + let (collection, form) = rest.rsplit_once('-')?; + let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; + if !collections.contains(&collection) { + return None; + } + match form { + "UCS2" | "UTF16" => Some(Unicode::Utf16), + "UTF8" => Some(Unicode::Utf8), + "UTF32" => Some(Unicode::Utf32), + _ => None, + } +} + +/// Calls `out` with each code of string `s` in Unicode form `form` in turn, and the character it +/// encodes (none for an invalid code: a lone surrogate, a byte that starts no UTF-8 character, a +/// value past U+10FFFF). +fn unicode_codes(s: &[u8], form: Unicode, mut out: impl FnMut(&[u8], Option<char>)) { + match form { + Unicode::Utf16 => { + let unit = |i: usize| s.get(i..i + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_be_bytes); + let mut at = 0; + while let Some(u) = unit(at) { + let next = unit(at + 2); + let pair = next.filter(|n| (0xd800..0xdc00).contains(&u) && (0xdc00..0xe000).contains(n)); + let len = if pair.is_some() { 4 } else { 2 }; + let c = char::decode_utf16([u].into_iter().chain(pair)).next().and_then(Result::ok); + out(s.get(at..at + len).unwrap_or_default(), c); + at += len; + } + } + Unicode::Utf8 => { + let mut at = 0; + for chunk in s.utf8_chunks() { + for c in chunk.valid().chars() { + out(s.get(at..at + c.len_utf8()).unwrap_or_default(), Some(c)); + at += c.len_utf8(); + } + for _ in chunk.invalid() { + out(s.get(at..at + 1).unwrap_or_default(), None); + at += 1; + } + } + } + Unicode::Utf32 => { + for b in s.as_chunks::<4>().0 { + out(b.as_slice(), char::from_u32(u32::from_be_bytes(*b))); + } + } + } +} + +/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose +/// codes are CIDs of no known character collection. +fn predefined(name: &str) -> Option<CMapName<'_>> { + match CMapName::from_bytes(name.as_bytes()) { + CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, + name => Some(name), + } +} + +/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` +/// (<https://github.com/adobe-type-tools/cmap-resources>): the nearest carried CMap with the same +/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC +/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space +/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. +fn stand_in(stem: &str) -> Option<Option<(&'static str, bool)>> { + Some(match stem { + "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), + "78-EUC" => Some(("EUC-H", false)), + "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), + "GB" => Some(("GB-EUC-H", true)), + "CNS1" => Some(("CNS-EUC-H", true)), + "KSC" => Some(("KSC-EUC-H", true)), + "B5" => Some(("ETen-B5-H", false)), + "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), + "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, + _ => return None, + }) +} + +/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A +/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two +/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with +/// its [`stand_in`]. +fn cid_codes(cmap: &str) -> Option<CMapCodes> { + let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); + let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); + let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); + let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { + let (name, high_bit) = stand_in(stem?).flatten()?; + Some((read(name)?, high_bit)) + })?; + let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; + let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; + // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. + let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; + Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) +} + +/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it +/// (<https://github.com/adobe-type-tools/cmap-resources>); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option<CodeSpace> { + const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; + const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; + const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; + const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; + const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; + const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; + const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; + const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; + const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; + const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; + const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; + const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; + const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; + const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + let stem = match cmap { + "H" | "V" => "", + _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, + }; + let stem = stem.strip_suffix("-HW").unwrap_or(stem); + Some(match stem { + "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, + _ if stem.ends_with("RKSJ") => RKSJ, + "EUC" | "78-EUC" => EUC_JP, + "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, + "GBpc-EUC" | "GBTpc-EUC" => GB_PC, + "GBK-EUC" | "GBKp-EUC" => GBK, + "GBK2K" => GBK2K, + "B5pc" => B5_PC, + "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, + "HKscs-B5" | "ETHK-B5" => HKSCS, + "HKdlb-B5" => HKDLB, + "HKgccs-B5" => HKGCCS, + "CNS-EUC" => CNS_EUC, + "KSCpc-EUC" => KSC_PC, + "KSCms-UHC" => UHC, + _ => return None, + }) +} + +/// The length of the code string `bytes` starts with: the length of the code space range whose +/// lowest and highest codes hold each of its bytes between them. `None` when no range does. +fn code_len(space: CodeSpace, bytes: &[u8]) -> Option<u8> { + space + .iter() + .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) + .map(|(lo, _)| lo.len()) + .min() + .and_then(|len| u8::try_from(len).ok()) +} + +/// Whether `cmap` names a predefined CMap: a predefined Unicode CMap ([`unicode_cmap`]), one of the +/// predefined CMaps VectorCraft carries ([`predefined`]) or one of the others ([`stand_in`]), in +/// its horizontal (`-H`) or vertical (`-V`) form. The Identity CMaps are not among them. +pub(super) fn known(cmap: &str) -> bool { + let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); + let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); + unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() +} + +/// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`). +pub(super) fn vertical(cmap: &str) -> bool { + cmap == "V" || cmap.ends_with("-V") +} + +/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a +/// predefined CMap ([`known`]). A name ending in an Identity CMap is not read as one, nor is one +/// that ends in an unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise +/// end in `H`. +pub(super) fn composite_font(name: &str) -> Option<(&str, &str)> { + if !(name.ends_with("-H") || name.ends_with("-V")) { + return None; + } + // The longest CMap name first: `83pv-RKSJ-H` before `H`. + // No predefined CMap name is longer than 32 bytes. + for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { + let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; + if matches!(cmap, "Identity-H" | "Identity-V") { + return None; + } + let encoding_part = + base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); + if matches!(cmap, "H" | "V") && encoding_part { + return None; + } + if known(cmap) { + return Some((base, cmap)); + } + } + None +} diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 2b01b2b4f2..9e452c4bf4 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -403,6 +403,171 @@ fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() { check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and"); } +/// The text objects of an import. +fn texts(r: &crate::Imported) -> Vec<vectorcraft_doc::TextObject> { + all(&r.document) + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((**t).clone()), + _ => None, + }) + .collect() +} + +/// A CID-keyed font over one of the other predefined CMaps (Shift-JIS, EUC, GBK, Big Five, UHC, +/// JIS row and cell: <https://github.com/adobe-type-tools/cmap-resources>), the file defining +/// neither, its CIDFont only named: the codes are read by that CMap and its character +/// collection's CIDs to Unicode, and set as type in the font of the CIDFont's name, whether +/// found as `CIDFont-CMap` or composed. Codes the CMap doesn't map and control codes are left out. +#[test] +fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { + let cases: &[(&str, &str, &str)] = &[ + // 日 is 0x93FA and 本 0x967B in Shift-JIS. + ("83pv-RKSJ-H", "93fa967b41", "日本A"), + ("90ms-RKSJ-H", "93fa", "日"), + ("EUC-H", "c6fc", "日"), + // JIS row and cell: 日 is 0x467C. + ("H", "467c", "日"), + ("GBK-EUC-H", "d6d0", "中"), + ("B5pc-H", "a4a4", "中"), + ("ETen-B5-H", "a4a4", "中"), + ("KSCms-UHC-H", "c7d1", "한"), + // A CMap read with the nearest carried one: GB 2312 row and cell in EUC form, 中 0x5650. + ("GB-H", "5650", "中"), + // 0x8540 maps to no CID, 0xFD is in no code space range, 0x0D is a control code. + ("90ms-RKSJ-H", "854093fa41fd0d42", "日AB"), + ]; + for (cmap, hex, want) in cases { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{cmap}: {:?}", r.warnings); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), [*want], "{cmap}"); + let style = &t[0].runs[0].style; + assert_eq!((style.font_family.as_str(), style.font_style.as_str(), style.size), ("Ryumin", "Light", 20.0), "{cmap}"); + assert!(!t[0].vertical, "{cmap}"); + } + let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto <d6d041> show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "中A"); + // The point moves past the type, along the line. + check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and"); +} + +/// Type in a CID-keyed font over a predefined CMap, its CIDFont only named, shown with `ashow`, +/// `widthshow` (matching a code by its value) and `xshow`, is spaced as the operator says: the +/// point moves that far, and the characters' tracking makes up the difference, as for type in a +/// font found by name. Over a vertical CMap the extra space runs down the column. +#[test] +fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { + // Measured without painting. + let measured = |body: &str| check(&format!("gsave nulldevice {body} grestore")); + let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; + // `ashow` adds 5 to each of the two codes. + measured(&format!( + "{set} 10 20 moveto <93fa967b> show currentpoint pop 10 20 moveto 5 0 <93fa967b> ashow currentpoint pop sub -10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 20 moveto 5 0 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["日本"]); + assert!(t[0].runs.iter().all(|run| (run.style.tracking - 250.0).abs() < 0.5), "{:?}", t[0].runs); + // `widthshow` adds 7 to the space, code 0x20. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 <93fa20967b> widthshow currentpoint pop sub -7 sub abs 0.01 lt" + )); + // A row-and-cell code read in its EUC form keeps the value of the string's bytes: 中 0x5650. + let gb = "/STSong-Light-GB-H findfont 20 scalefont setfont"; + measured(&format!( + "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt" + )); + // `xshow` moves 30 a code. + measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); + // Down the column: `ashow`'s 5 down each code. + let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + measured(&format!( + "{set} 10 200 moveto <93fa967b> show currentpoint exch pop 10 200 moveto 0 -5 <93fa967b> ashow currentpoint exch pop sub 10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 200 moveto 0 -5 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::<String>(), "日本"); + assert!(t.iter().all(|t| t.vertical), "{t:?}"); +} + +/// A CID-keyed font over a vertical predefined CMap (`-V`), the file defining neither, its CIDFont +/// only named: the type is a vertical column, and the point moves down it, not along a line. +#[test] +fn named_cidfonts_over_vertical_cmaps_set_vertical_type() { + for (cmap, hex) in [("90ms-RKSJ-V", "93fa967b"), ("UniJIS-UCS2-V", "65e5672c")] { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["日本"], "{cmap}: {:?}", r.warnings); + assert!(t[0].vertical, "{cmap}"); + check(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont <{hex}> stringwidth dup -60 gt exch -5 lt and exch abs 0.01 lt and")); + } +} + +/// Down a column each code of a CID-keyed font over a vertical predefined CMap moves the point one +/// em, the default vertical displacement (PDF Reference 1.7, §5.6.3, `DW2`), also two digits that +/// vertical type sets side by side in one em: `show` and `stringwidth` move the point as far as +/// showing the codes one at a time. +#[test] +fn codes_down_a_column_each_move_the_point_one_em() { + // Measured without painting. + let set = "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + check(&format!("{set} (12) stringwidth grestore -40 sub abs 0.01 lt exch abs 0.01 lt and")); + check(&format!("{set} 10 100 moveto (12) show currentpoint grestore 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + check(&format!( + "{set} 10 100 moveto (1) show (2) show currentpoint 10 100 moveto (12) show currentpoint grestore \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); +} + +/// Type in a CID-keyed font over a predefined CMap is set the same way by `show` and by the show +/// operators that space it: characters the font would join in a ligature (`fi` in the bundled +/// Source Serif 4) are set without the ligature, and the point moves as far. +#[test] +fn named_cid_type_is_set_alike_by_every_show_operator() { + let set = "/SourceSerif4-Regular-UniJIS-UCS2-H findfont 50 scalefont setfont 10 20 moveto"; + let features = |show: &str| { + let r = super::tests::read(&format!("{set} <00660069> {show}")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["fi"], "{show}"); + t[0].runs.iter().map(|r| r.style.features.clone()).collect::<Vec<_>>() + }; + let plain = features("show"); + assert_eq!(plain, features("0 0 3 -1 roll ashow")); + assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}"); + check(&format!( + "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \ + 20 sub 3 -1 roll sub abs 0.01 lt 3 1 roll 10 sub sub abs 0.01 lt and" + )); +} + +/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of +/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't +/// available is left out with a warning, and a long string in a CID-keyed font over a predefined +/// CMap shown in a loop runs out of the budget. +#[test] +fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { + let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["AB"]); + let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); + assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); + assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); + super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop"); +} + +/// A string in the file, in a CID-keyed font over a predefined CMap, too long to decode within the +/// memory budget stops the program with an error before it is decoded. +#[test] +fn huge_strings_over_predefined_cmaps_count_against_the_memory_budget() { + // Codes the CMap doesn't map, so nothing is laid out when the string is decoded. + let huge = "\u{1}".repeat(17 << 20); + let r = import(&super::tests::eps(&format!( + "0 0 10 10 rectfill /Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont 10 20 moveto ({huge}) stringwidth" + ))) + .unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + /// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the /// file doesn't define, sets its characters as type at the font's size, not a thousand times it. #[test] diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 5868aa9228..ee9b0fc759 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -15,6 +15,7 @@ use super::cid::Base; use super::graphics::MAX_APART; use super::interp::{Interp, matrix_obj, matrix_of}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; +use super::predefined_cmap::Decoded; use super::type1::Embedded; /// The font a program uses before it sets one. @@ -170,8 +171,8 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - if let Some((text, font)) = self.unicode_type(&s)? { - self.show_unicode(&text, &font)?; + if let Some((codes, font, vertical)) = self.named_cid_type(&s)? { + self.show_named_cid(&codes, &font, vertical, &spacing)?; } else if let Some(glyphs) = self.composite_glyphs(&s)? { self.show_composite(&glyphs, &spacing)?; } else { @@ -282,9 +283,9 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res<Vec2> { - if let Some((text, font)) = self.unicode_type(s)? { + if let Some((codes, font, vertical)) = self.named_cid_type(s)? { let saved = self.g.font.replace(font); - let r = self.set_text(&text, Point::ZERO).map(|t| t.2); + let r = self.named_cid_width(&codes, vertical); self.g.font = saved; return r; } @@ -395,18 +396,29 @@ impl Interp<'_> { } /// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap - /// is a predefined Unicode CMap, the CID-keyed font of those two (the usual name of a - /// CID-keyed font, as `composefont` makes them: PLRM 3rd ed., chapter 8); else a font of - /// that name. + /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font, + /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap + /// resources, <https://github.com/adobe-type-tools/cmap-resources>); else a font of that name. + /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the + /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so + /// `Futura-H` stays a font of that name. fn find_font(&mut self, k: &Obj) -> Res<DictRef> { let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { return Ok(d.clone()); } let text = k.text().map(|t| t.to_string()).unwrap_or_default(); - let split = - text.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()); - if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) { + let split = text + .match_indices('-') + .map(|(i, _)| i) + .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) + .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) + .or_else(|| { + let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?; + let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some(); + (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap)) + }); + if let Some((cid_font, cmap)) = split { let cmap = self.find_cmap(&Obj::name(cmap))?; let dep = self.find_cid_font(&Obj::name(cid_font))?; return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)])); @@ -476,6 +488,15 @@ impl Interp<'_> { /// [`Self::set_type`] for the characters `text`. fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + self.set_text_in(text, at, false) + } + + /// [`Self::set_text`], as vertical type when `vertical`: a column anchored at `at`, the top + /// center of its first em box, that moves the current point down the column by the layout's + /// advances. In vertical writing the current point is a glyph's origin 1, by default half its + /// width across and 880 units of 1000 above its origin 0 (PDF Reference 1.7, §5.6.3 "Glyph + /// Metrics in CIDFonts", `DW2`). + fn set_text_in(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout, Vec2)> { // Laying out type costs about as much as 300 operations, and 64 more a character // (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends // within the budget like any other loop instead of running for minutes. @@ -493,11 +514,29 @@ impl Interp<'_> { let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); + t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size); t.cached_bounds = Some(layout.bounds); let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum(); - Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO)) + let moved = if vertical { Point::new(0.0, -adv / size) } else { Point::new(adv / size, 0.0) }; + Ok((t, layout, f1 * moved - f1 * Point::ZERO)) + } + + /// [`Self::set_text_in`] for characters the file places one by one: where the layout joins + /// characters in a ligature, the type is set without ligatures, so each character has its own + /// advance. + fn set_placed_text(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout)> { + let (mut t, mut layout, _) = self.set_text_in(text, at, vertical)?; + let glyph_starts: std::collections::HashSet<usize> = layout.glyphs.iter().map(|g| g.byte).collect(); + if text.char_indices().any(|(b, _)| !glyph_starts.contains(&b)) { + for run in &mut t.runs { + run.style.features.push("-liga".into()); + } + layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + t.cached_bounds = Some(layout.bounds); + } + Ok((t, layout)) } /// `show`: point type at the current point, which moves past it. @@ -527,27 +566,30 @@ impl Interp<'_> { return self.show_type42_outlines(data, glyphs, s, spacing, at, f1); } // Each code's character, if it has one that shows (control codes don't). - let chars: Vec<Option<char>> = match &plain { + let chars = match &plain { Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::<Vec<_>>(), None => self.named_chars(s).into_iter().map(Some).collect(), + }; + let mut codes = Decoded::default(); + for (c, &code) in chars.into_iter().zip(s) { + codes.push(u32::from(code), c.filter(|c| !c.is_control())); } - .into_iter() - .map(|c| c.filter(|c| !c.is_control())) - .collect(); - let text: String = chars.iter().flatten().collect(); - let (mut t, mut layout, _) = self.set_text(&text, at)?; + let widths: Option<Vec<f64>> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); + self.show_codes(&codes, widths.as_deref(), spacing, false, s) + } + + /// The codes `codes` (each standing for some of its characters, or none) shown in the current + /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths` + /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances; + /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the + /// string `kshow` gives its procedure the codes of. + fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res { + let at = self.user_point()?; + let (_, f1) = self.font()?; + let text = &codes.text; + let (mut t, layout) = self.set_placed_text(text, at, vertical)?; let size = t.runs.first().map_or(1.0, |r| r.style.size); let starts: Vec<usize> = text.char_indices().map(|(b, _)| b).collect(); - // The file places each character's glyph: where the layout joins characters in a - // ligature, the type is set without ligatures, so each character has its own advance. - let glyph_starts: std::collections::HashSet<usize> = layout.glyphs.iter().map(|g| g.byte).collect(); - if starts.iter().any(|b| !glyph_starts.contains(b)) { - for run in &mut t.runs { - run.style.features.push("-liga".into()); - } - layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - t.cached_bounds = Some(layout.bounds); - } // Each character's advance in the layout (text space). let mut natural = vec![0.0; starts.len()]; for g in &layout.glyphs { @@ -555,29 +597,30 @@ impl Interp<'_> { *n += g.advance; } } - // Each code's move (user space) and that move in text space, along the baseline and across. - let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO; + // Each code's move (user space) and that move in text space, along the baseline (down the + // column) and across. + let em = |x: f64| f1 * if vertical { Point::new(0.0, -x) } else { Point::new(x, 0.0) } - f1 * Point::ZERO; let inv = f1.inverse(); - let mut moves = Vec::with_capacity(s.len()); + let mut moves = Vec::with_capacity(codes.codes.len()); let mut k = 0; // What `kshow`'s procedure draws goes after the type. let start = self.out.drawn.len(); let mut pen = at; - for (i, (&code, c)) in s.iter().zip(&chars).enumerate() { - let own = match &plain { - Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width), - None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size, - None => 0.0, + for (i, &(code, n)) in codes.codes.iter().enumerate() { + let own = match widths { + Some(widths) => widths.get(i).copied().unwrap_or(0.0), + None => natural.get(k..k + n).map_or(0.0, |w| w.iter().sum::<f64>()) / size, }; - k += usize::from(c.is_some()); + k += n; let mut d = spacing.advance(i, i64::from(code), em(own)); - d += self.kern(spacing, s, i, pen + d)?; + d += self.kern(spacing, kern, i, pen + d)?; pen += d; let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO; - if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) { + let (along, across) = if vertical { (-e.y * size, e.x * size) } else { (e.x * size, e.y * size) }; + if ![d.x, d.y, along, across].iter().all(|v| v.is_finite()) { return ps_err("undefinedresult", "a show"); } - moves.push((d, e.x * size, e.y * size)); + moves.push((d, along, across)); } let total: Vec2 = moves.iter().map(|m| m.0).sum(); if !(total.x.is_finite() && total.y.is_finite()) { @@ -589,12 +632,11 @@ impl Interp<'_> { if across { // Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it. let mut pen = at; - for ((d, ..), c) in moves.iter().zip(&chars) { - if let Some(c) = c - && painted - && !c.is_whitespace() - { - let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?; + let mut chars = text.chars(); + for ((d, ..), &(_, n)) in moves.iter().zip(&codes.codes) { + let c: String = chars.by_ref().take(n).collect(); + if painted && !c.trim().is_empty() { + let (t, ..) = self.set_text_in(&c, pen, vertical)?; let clips = self.g.clips.clone(); self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); } @@ -605,11 +647,17 @@ impl Interp<'_> { // a character, before the next, are that character's. let mut lead = Vec2::ZERO; let mut target: Vec<f64> = Vec::with_capacity(natural.len()); - for ((d, along, _), c) in moves.iter().zip(&chars) { - match (c, target.last_mut()) { - (Some(_), _) => target.push(*along), - (None, Some(last)) => *last += along, - (None, None) => lead += *d, + for ((d, along, _), &(_, n)) in moves.iter().zip(&codes.codes) { + if n > 0 { + // A code of several characters: those before its last keep their advances. + let before = natural.get(target.len()..target.len() + n - 1).unwrap_or_default().to_vec(); + let rest = *along - before.iter().sum::<f64>(); + target.extend(before); + target.push(rest); + } else if let Some(last) = target.last_mut() { + *last += along; + } else { + lead += *d; } } let tracking: Vec<f64> = target @@ -931,23 +979,47 @@ impl Interp<'_> { .collect()) } - /// The characters of `s` and the font to set them in when the current font is a CID-keyed - /// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose - /// mappings to CIDs aren't known: its codes say the characters, set as type in the font of - /// its CIDFont's name. - fn unicode_type(&mut self, s: &[u8]) -> Res<Option<(String, DictRef)>> { - let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) }; + /// The codes of `s` with the characters each stands for, the font to set them in and whether + /// they run down a column, when the current font is a CID-keyed font over a predefined CMap + /// the file doesn't define (`UniJIS-UCS2-H`, `90ms-RKSJ-H`), whose mappings to CIDs the file + /// doesn't give: a Unicode CMap's codes are the characters, another CMap's are read by the CMap + /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map + /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical + /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't + /// read is left out, with a warning. + fn named_cid_type(&mut self, s: &[u8]) -> Res<Option<(Decoded, DictRef, bool)>> { + let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; + // Each code is looked up in the CMap's mappings. + self.spend(s.len() as u64)?; + // The decoded codes and characters are held while the string is shown: checked against + // the memory budget first, so a huge string in the file stops the program with an error. + let held = s.len().saturating_mul(DECODED_BYTES); + self.alloc(held)?; + self.unalloc(held); + let decoded = match self.predefined_codes(&cmap) { + Some(codes) => codes.codes(s), + None => { + self.out.warn(UNREAD_CMAP); + Decoded::default() + } + }; + let mut codes = Decoded::default(); + let mut chars = decoded.text.chars(); + for &(code, n) in &decoded.codes { + codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); + } // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. let entries = font.borrow().len(); self.spend(entries as u64)?; let mut named = font.borrow().clone(); named.insert(Key::name("FontName"), name); - Ok(Some((text, DictRef::new(named)))) + Ok(Some((codes, DictRef::new(named), vertical))) } - /// The text, the composite font and the CIDFont's name of [`Self::unicode_type`]. - fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> { + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of + /// [`Self::named_cid_type`]. + fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { let font = self.g.font.clone()?; let f = font.borrow(); if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) { @@ -959,29 +1031,63 @@ impl Interp<'_> { if cmap.contains_key(&Key::name("CodeSpaceRange")) { return None; } - let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?; + let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); + if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) { + return None; + } + let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num); let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None }; let name = { let d = dep.borrow(); d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()? }; - Some((super::cid::unicode_text(s, form), font.clone(), name)) + Some((cmap_name, font.clone(), name, wmode == Some(1.0))) } - /// `text` set as point type at the current point in font `font` (see - /// [`Self::unicode_type`]), which moves past it. - fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res { - let at = self.user_point()?; - let saved = self.g.font.replace(font.clone()); - let r = self.set_text(text, at); - self.g.font = saved; - let (t, _, adv) = r?; - if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() { - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a + /// file); `None` when VectorCraft can't read it. + fn predefined_codes(&mut self, cmap: &str) -> Option<Rc<super::predefined_cmap::CMapCodes>> { + if let Some((form, _)) = super::cid::unicode_cmap(cmap) { + return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } - self.g.cur = Some(self.xf() * (at + adv)); - Ok(()) + if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { + return codes.clone(); + } + let codes = super::predefined_cmap::codes(cmap).map(Rc::new); + // Only the names of the other predefined CMaps reach here (`named_cid_parts`), so the + // list stays short. + self.predefined_cmaps.push((Rc::from(cmap), codes.clone())); + codes + } + + /// The codes `codes` (with their characters) set as point type at the current point in font + /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves + /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and + /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed., + /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's + /// codes aren't single bytes). Down a column each code moves the point one em, the default + /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, + /// §5.6.3, `DW2`). + fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res { + let widths = vertical.then(|| vec![1.0; codes.codes.len()]); + let saved = self.g.font.replace(font.clone()); + let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]); + self.g.font = saved; + r + } + + /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`] + /// shows them with no extra spacing. + fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res<Vec2> { + let (_, f1) = self.font()?; + let moved = if vertical { + Point::new(0.0, -(codes.codes.len() as f64)) + } else { + let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; + let size = t.runs.first().map_or(1.0, |r| r.style.size); + Point::new(layout.glyphs.iter().map(|g| g.advance).sum::<f64>() / size, 0.0) + }; + Ok(f1 * moved - f1 * Point::ZERO) } /// The characters of a composite font shown at the current point, spaced as `spacing` says. @@ -1282,6 +1388,13 @@ const MAX_TYPE1: usize = 256; /// The warning for CID-keyed type in a CIDFont the file doesn't carry. const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out"; +/// Most bytes [`Interp::named_cid_type`] holds for each byte of a string: two lists of codes (16 +/// bytes a code) and their characters (up to 4 bytes a character, a few characters a code). +const DECODED_BYTES: usize = 64; + +/// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have. +pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out"; + /// Most Type 42 fonts whose TrueType data is kept between glyphs. const MAX_TYPE42: usize = 16; From 6849bd54222c485e12244985f2ef7f1ddb85d46c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 05:12:10 -0800 Subject: [PATCH 72/72] Docs: docs/plugins.md paragraphs wrap at the file's width Rewrap the prose in docs/plugins.md whose lines broke well short of the file's 120-column width or ran past it, including the Safe Mode sentence, the permission-name and install-confirmation sentences, and the grants.json paragraph. Each rewrapped line is at most 120 columns. No words, punctuation, code blocks or tables change. --- docs/plugins.md | 308 ++++++++++++++++++++++++------------------------ 1 file changed, 151 insertions(+), 157 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index d3af5fa06c..f616479167 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,9 +1,9 @@ # Plug-ins: the WebAssembly plug-in API (ABI v1) VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into -arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** -run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's -plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** run in a +sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same +sandbox, same manifest and parameter schema), extended for vector documents. There are four kinds of plug-in: @@ -20,8 +20,8 @@ There are four kinds of plug-in: paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host hands them the file's bytes. -This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the -`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). +This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands +in `crates/engine/src/cmd/plugin.rs`). ## Using plug-ins @@ -44,14 +44,15 @@ An exporter adds a write-only format, `plugin.<id>`, which `document.formats` li its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. An importer adds a read-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id. `openExtensions` -holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format -reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the -one whose id sorts first opens it. `document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer, -which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` -only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an -importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, -and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens as a new document when it is dropped on the canvas. These rules are -VectorCraft's own. +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open +{"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format reads the file, by its content or +by its extension, and the importer lists the file's extension; when several do, the one whose id sorts first opens it. +`document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer, which must list the file's extension, +also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a +built-in format reports that format's error, and the file is not handed to an importer. The importer's objects become a +new document whose artboard is their bounds (at least 1 pt wide and high, and within ±4,000,000 pt); the document keeps +no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens +as a new document when it is dropped on the canvas. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with @@ -60,25 +61,23 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where it can be given a shortcut (`plugin:<id>`); the shortcut stays saved while the plug-in is removed and applies again when it is reinstalled. Parameters too many for the window's height scroll inside the dialog, which keeps its -heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use -Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › -Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New -from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's -extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, -`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give -those files to the importer, and Open With does. **File › Open With** lists the -installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its -extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. On the -desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which -stops it. An importer -with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open -button opens the file; File › Open runs an importer with its parameters' defaults. Open Recent opens a file the way -File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the -file if one can, and otherwise the first importer by id that lists its extension reads it with its parameters' defaults. -**File › Open** of a `.wasm` file installs -a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out +(the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is +picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and +under a filter of their own, named after the importer (other open dialogs, such as New from Template and Load Styles, +list only the files the built-in formats read). These lists leave out an importer's extensions that a built-in format +reads, and those File › Open installs, imports or loads as something else (`.wasm`, `.kys`, `.jsx`, presets, libraries +and swatch or graphic style libraries such as `.ase`). File › Open does not give those files to the importer, and Open +With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, +"params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker +opens instead) and adds it to Open Recent. On the desktop the importer runs off the UI thread, and a run that takes a +while shows a progress window with Cancel, which stops it. An importer with parameters, given no `params`, asks for them +in a dialog of the same kind once the file is picked, and its Open button opens the file; File › Open runs an importer +with its parameters' defaults. Open Recent opens a file the way File › Open does: it does not remember the importer or +the parameters given to Open With. A built-in format reads the file if one can, and otherwise the first importer by id +that lists its extension reads it with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in +too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background +([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). ### Commands @@ -117,9 +116,9 @@ the object is drawn as if the effect weren't there, and the record is kept for w ## The sandbox -The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter -(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits -(`vectorcraft_plugins::Limits`, defaults): +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). It +builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits (`vectorcraft_plugins::Limits`, +defaults): | Limit | Default | On violation | |---|---|---| @@ -155,15 +154,15 @@ measured with the page's `performance.now()`. Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and -an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s -(`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how -much there is only once the run has ended. +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, +and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns +how much there is only once the run has ended. - **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`, and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants` - (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again - at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder + (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed + again at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder asks nobody. - **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a thread of its own while editing goes on, shown in the status bar and, after a moment, in a progress window with @@ -203,17 +202,15 @@ capability model proposed for PhotoCraft plug-ins `state`, which only its commands use. A filter may list any of its three, and `commands` with a `commandAllowlist`. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, `progress`, `settings`, `cache` or `commands` with an `api` - below 2, or - one of the names kept for a later plug-in API - (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. + below 2, or one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is + installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the - SHA-256 shown; Cancel installs nothing. The - names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. - Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) - are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't - change what it installs. OK stays disabled until the question has been on screen in the focused window for half a - second (VectorCraft's own value, from the delay the security guidance at + SHA-256 shown; Cancel installs nothing. The names shown have control and bidirectional characters escaped (`\u{202E}`) + and are cut to 64 characters. Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected + input (`ui.key`, `ui.click`) are refused and the dialog stays open. Enter confirms it only when OK has the keyboard + focus. `ui.dialog.set` can't change what it installs. OK stays disabled until the question has been on screen in the + focused window for half a second (VectorCraft's own value, from the delay the security guidance at 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 @@ -280,23 +277,22 @@ capability model proposed for PhotoCraft plug-ins 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, + 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 id, the process forgets its own grant for that id, and a grant kept in `grants.json` stays with the stored module it covers. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again, replaced by a module that lists no permissions, or removed. When such an entry can't be removed from the file, `plugin.install` and `plugin.remove` treat it as a grant - that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than - 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept - in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins - Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written + larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for + `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in + is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins Folder and + `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a `permissions` list, or with an empty one, needs no grant. -- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its - `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the - document is refused. A refused output leaves the document as it was. +- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; + without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that + changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). A higher one @@ -307,12 +303,11 @@ capability model proposed for PhotoCraft plug-ins - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its -[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed -modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the -document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that -fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). A run that succeeds clears the -error of a failed run. A check that finds every swatch and pattern a painted result names clears only the error of a -refused swatch or pattern. +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range +pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's +output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; +`plugin.info` reports why (`lastError`). A run that succeeds clears the error of a failed run. A check that finds every +swatch and pattern a painted result names clears only the error of a refused swatch or pattern. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts @@ -365,12 +360,12 @@ still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; t filter that lists `progress` runs in the background as a heavy one does, under its light budget, when it runs at once from Object › Plug-ins or its shortcut, or when OK runs it with Preview off; with Preview on, its dialog keeps the previewed run. A filter that also lists a `commandAllowlist` runs where it is called, since its command calls need - the document. A plug-in that checks `vc_cancelled` can - stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that - ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when - it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports - in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A - live effect's progress is not shown, since it is drawn as part of something else. + the document. A plug-in that checks `vc_cancelled` can stop early, and the host discards what it returns after the + Cancel or after its time budget ran out; one that ignores it is still stopped by the instruction and time budgets. + With a known total, a report is passed on only when it reaches another tenth of a percent; with an unknown total, each + change of `done` is passed on. After 2,000 reports in a run, one change in 1,000 is passed on, and so is the first + report after the total becomes known or unknown. A live effect's progress is not shown, since it is drawn as part of + something else. - Log lines go to the app's log (target `vectorcraft_plugins`) as `<plug-in id>: <text>`, with control and bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown @@ -405,8 +400,7 @@ or modified time differs from the last read or write. A get answered from memory set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and -the run waits for it. -A live effect runs while the art draws, so keep the entries it uses small. +the run waits for it. A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -416,28 +410,27 @@ A live effect runs while the art draws, so keep the entries it uses small. also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in it. - The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` - folder (ids that differ only in case get two folders): `settings.json` and a - `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is - moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that - folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed - write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. + folder (ids that differ only in case get two folders): `settings.json` and a `cache` folder with one file per entry, + named by the host. A settings file that is too large or doesn't parse is moved to `settings.unreadable.json`, + replacing an earlier one there, and the settings start empty. A link in that folder is refused (`-5`). Writes go to a + temporary file that then replaces the old one; a temporary file a failed write leaves in the `cache` folder does not + count toward the caps, and clearing the cache removes it. - A plug-in reaches only the storage of its own id. Replacing it with another module of the same id - (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. - `plugin.remove` first renames the folder, adding `.removed` to its name (or `.removing` while a `.removed` folder - holds settings an earlier removal kept). When that fails, `plugin.remove` fails and the plug-in stays installed with - its storage as it was. Before VectorCraft deletes a renamed folder, it writes an empty file beside it with `.delete` - added to the folder's name, and removes that file once the folder is gone. When that file can't be written, the - folder is not deleted and is treated like any renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the - renamed folder with the `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again - to delete them when a plug-in with that id is installed or a storage folder for the id exists, and that deletion can - fail too. For an id that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as - not found. When `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the - renamed folder. The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no - `.delete` file back while the plug-in has no folder of its own; when it has one, that folder is used and the renamed - folder stays until `plugin.remove` deletes both. - `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is - any. - `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a + run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder, + adding `.removed` to its name (or `.removing` while a `.removed` folder holds settings an earlier removal kept). When + that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Before VectorCraft + deletes a renamed folder, it writes an empty file beside it with `.delete` added to the folder's name, and removes + that file once the folder is gone. When that file can't be written, the folder is not deleted and is treated like any + renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the renamed folder with the + `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a + plug-in with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id + that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as not found. When + `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the renamed folder. + The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no `.delete` file back + while the plug-in has no folder of its own; when it has one, that folder is used and the renamed folder stays until + `plugin.remove` deletes both. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept + for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`. - Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. @@ -458,8 +451,9 @@ has the id, `-6` when a plug-in may not call the command, `-7` when the command `-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when nothing can be called (from the module's start function, in a run that isn't a filter's run on a document, or after a call in the run failed). -- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, - traps, runs out of budget or is canceled leaves every document, its selection and its history as they were. +- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The + run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, traps, runs + out of budget or is canceled leaves every document, its selection and its history as they were. - Each call runs on the document the run is for, with its params checked as any request's are, and sees what the calls before it changed: a `select.all` followed by `object.move` moves everything. The filter's own input is the one it was given when the run started; an edit still in progress (typing, or a shape being drawn) is kept as its own undo @@ -540,18 +534,17 @@ the module's start function, in a run that isn't a filter's run on a document, o no empty part between dots. A malformed list is refused when the module is installed, and the error names the id. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are - clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults - are also clamped to their range, including the implicit zero default of an `int` parameter. Besides `number`, - `int`, `bool` and `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; - longer text is cut to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, - "max": 360, "default": [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, - each clamped to `min..max`; a list of too few or too many is an error, and `maxItems` is at most 256) and `color` - (`{"type": "color", "default": "#3a7bd5"}`: a color button that opens a color picker; the default, black when left - out, and a value given to `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in - always gets a color object, its components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a - hex string and the picker's choice are display colors, given as the working RGB color that shows as them, so in a - working RGB space other than sRGB the numbers differ from the hex). Hosts from before `color` refuse a manifest that - uses it. A plug-in declares up to 256 + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults are also clamped + to their range, including the implicit zero default of an `int` parameter. Besides `number`, `int`, `bool` and + `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; longer text is cut + to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, "max": 360, "default": + [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, each clamped to `min..max`; a + list of too few or too many is an error, and `maxItems` is at most 256) and `color` (`{"type": "color", "default": + "#3a7bd5"}`: a color button that opens a color picker; the default, black when left out, and a value given to + `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in always gets a color object, its + components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a hex string and the picker's choice + are display colors, given as the working RGB color that shows as them, so in a working RGB space other than sRGB the + numbers differ from the hex). Hosts from before `color` refuse a manifest that uses it. A plug-in declares up to 256 parameters, at most 64 of them outside groups. Each parameter may also say how dialogs show it. Hosts from before these keys ignore them, so a plug-in that @@ -576,17 +569,18 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and -symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one -object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts -from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a -fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live -effects](#painted-live-effects)). +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own +objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and +`strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may +draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry, unless it +declares `paintOutput` ([painted live effects](#painted-live-effects)). An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF -renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, -appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks -`version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME -type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +renditions or save-preference metadata (the format is documented in +[`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, +artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host +may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their +data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. @@ -615,12 +609,11 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), (absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down. - `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. A stroke has its `paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`) - and `miterLimit`. Paints use the - native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": - {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", - "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", - c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except - Lab's. + and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, + `{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, + midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0 + white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or `plugin.<id>` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). @@ -631,9 +624,9 @@ A filter whose manifest lists `inputs` also gets the selected objects of those k and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the -object has been laid out or drawn) and -`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. -In this example the character and paragraph styles are cut short; a filter gets every attribute: +object has been laid out or drawn) and `transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, +the symbol's art) into the document. In this example the character and paragraph styles are cut short; a filter gets +every attribute: ```json {"objects": [ @@ -662,13 +655,13 @@ In this example the character and paragraph styles are cut short; a filter gets `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size from those 16 MiB even when decoding fails. A larger image, one whose pixels don't fit what the images before - it in paint order left, and a linked image (`linked: true`) have - `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any - size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when - its data cannot be decoded (including a non-CMYK image whose decoder enforces the host's 64 MiB memory limit and - needs more, and a TIFF the host rejects as too large), or when the dimensions in its data differ from `pixelSize`. - A plug-in checks `pixelsOmitted` before it reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never - reads a linked image's file for a filter and never gives its path. + it in paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks + an image's size before it decodes the image. An image of any size also has `pixelsOmitted: true` when its data is + missing from the document, when its width or height is 0, when its data cannot be decoded (including a non-CMYK image + whose decoder enforces the host's 64 MiB memory limit and needs more, and a TIFF the host rejects as too large), or + when the dimensions in its data differ from `pixelSize`. A plug-in checks `pixelsOmitted` before it reads + `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never reads a linked image's file for + a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. @@ -699,16 +692,16 @@ toward the 64 MiB output cap. ``` `mode` is `"filter"`, `"effect"`, `"export"`, `"import"` or `"command"` (a [command](#commands) run, whose -`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference -box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard -under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document -(`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the -object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, -counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", -"name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. An -import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being -opened and its name without the extension. +`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the +effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the +selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until +it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or +`{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in +`fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: +the extension of the file being written (one the exporter lists) and its name without the extension (the document's name +when the export is not given a path), which some formats keep in their header. An import's `_context` is `{"mode": +"import", "extension": "plt", "name": "Poster"}`: the extension of the file being opened and its name without the +extension. ### Output @@ -785,17 +778,17 @@ is a piece drawn by itself, in the order returned, with paints of its own: path or compound path is hit-tested on its source path and appearance. On an object with no geometry effect of its own, Pathfinder effects and compound shapes use the object's path for an effect on a fill or stroke, as they do for every other effect on a fill or stroke; on an object that has one, they also use the pieces of a painted effect on a - fill or stroke. A 3D effect later in the stack uses the - object's input path when the pieces name a swatch or pattern. Each piece counts against the budget of the - object's geometry effects as well as its anchors, so all the painted effects in a stack make at most 100,000 pieces - together; a run whose pieces would pass the budget draws its input as it was. + fill or stroke. A 3D effect later in the stack uses the object's input path when the pieces name a swatch or pattern. + Each piece counts against the budget of the object's geometry effects as well as its anchors, so all the painted + effects in a stack make at most 100,000 pieces together; a run whose pieces would pass the budget draws its input as + it was. - The result is checked whole before any of it is drawn: the limits of a filter's output (100,000 objects, 4,000,000 anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke - widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every pattern named in - the document. One fault refuses the whole result: that step of the stack draws its input as it was, the effect - record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example - `Test Tone: plug-in output: objects[1].strokes[0].width: …`. + widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every + pattern named in the document. One fault refuses the whole result: that step of the stack draws its input as it was, + the effect record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example `Test Tone: + plug-in output: objects[1].strokes[0].width: …`. - The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an ordinary path or compound path with its fills and strokes, as one undo step. Object › Expand of gradient and pattern @@ -823,12 +816,13 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never -touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests -build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a -base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and -`wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as +long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, +which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters +(with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, +and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows +in a live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host +functions. ## Performance