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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 14:41:24 -0800 Subject: [PATCH 07/15] 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 e609b26a95946f0330bc87ce3cb938ad9310767f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:55:07 -0800 Subject: [PATCH 08/15] =?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 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 09/15] 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 c35d5f5b347c360e0cc2a33f6ad0082df7f7ec00 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 01:03:41 -0800 Subject: [PATCH 10/15] 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 11/15] 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 12/15] 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 13/15] 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 842f1f1b68e5411e35c51c8089ce146125d9f0f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 05:01:14 -0800 Subject: [PATCH 14/15] 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 15/15] 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