From f0e50437c22649353e53f80ac6f572d7df686420 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Wed, 11 Nov 2026 23:16:03 +0200 Subject: [PATCH 01/31] Save on a read-only location offers Save As A plain Save that the location refuses (read-only filesystem or permission denied) now opens Save As with the name filled in, instead of ending in an error dialog. The refusal travels as a typed engine error (READ_ONLY_LOCATION), so the control protocol and MCP still return an error and never open a panel. Recovery failures, explicit destinations, a busy picker and Close/Quit keep their existing flow. Closes #6146 --- apps/vectorcraft/src/host_services.rs | 2 + crates/engine/src/cmd/fileio.rs | 11 +- crates/engine/src/cmd/fileio/access.rs | 25 ++ crates/engine/src/cmd/fileio/save.rs | 9 +- .../engine/src/cmd/fileio/tests_readonly.rs | 52 ++++ crates/engine/src/error.rs | 7 + crates/engine/src/lib.rs | 2 +- crates/format/src/atomic.rs | 48 +++- crates/format/src/lib.rs | 2 +- crates/mcp/src/tests/fileio.rs | 23 ++ crates/ui-egui/src/io.rs | 5 + crates/ui-egui/src/io/read_only_save.rs | 64 +++++ crates/ui-egui/src/io/save.rs | 96 ++++++- crates/ui-egui/src/io/tests_read_only_save.rs | 266 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/menus/run.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/picks.rs | 9 + crates/ui-egui/src/services.rs | 11 + docs/control-protocol.md | 25 +- docs/file-format-parity.md | 5 + docs/gaps.md | 5 +- docs/mcp.md | 25 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 9 +- docs/ui-parity.md | 4 + 26 files changed, 684 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_readonly.rs create mode 100644 crates/ui-egui/src/io/read_only_save.rs create mode 100644 crates/ui-egui/src/io/tests_read_only_save.rs diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde7..bd7c8aec80 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -358,6 +358,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), + save_write: Some(std::sync::Arc::new(fileio::write_save_file)), + save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)), // Native saves stream into the file as they are encoded. stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4c..2b7969a434 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -89,9 +89,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -120,7 +120,10 @@ mod formats; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::write_file_with; +pub use access::{write_file_with, write_save_file, write_save_file_with}; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_readonly; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..0e69147a32 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -89,6 +89,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other +/// writers keep their existing errors; an automation-root refusal is never reclassified. +pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> { + write_save_file_with(path, &mut |w| w.write_all(bytes)) +} + +/// [`write_save_file`] for a streamed save. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; + may_write(path)?; + vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError { + let reason = format!("{path}: {error}"); + if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) } +} + +#[cfg(target_arch = "wasm32")] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + write_file_with(path, fill) +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn create_dir(path: &str) -> Result<()> { may_write(path)?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..791c25a9a2 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -157,6 +157,11 @@ fn documents_folder() -> Option { Some(home.join("Documents")) } +/// A Save As after a refused Save starts in Documents, else home, never the refused folder. +pub fn documents_dialog_folder() -> Option { + documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder) +} + /// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't /// set, is relative, or is the home folder itself (the spec's way of turning it off). fn xdg_documents(text: &str, home: &std::path::Path) -> Option { @@ -1032,7 +1037,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let out = job.write_streamed(&mut super::write_file_with)?; + let out = job.write_streamed(&mut super::write_save_file_with)?; job.complete(s, uid); Ok(out) } @@ -1104,7 +1109,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs new file mode 100644 index 0000000000..badf82a8cf --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_readonly.rs @@ -0,0 +1,52 @@ +//! Save errors retain their type without changing the other filesystem writers (#6146). + +use std::io::{self, ErrorKind}; + +use serde_json::json; + +use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with}; +use crate::{EngineError, READ_ONLY_LOCATION, Session}; + +#[test] +fn readonly_error_is_typed_and_other_writers_keep_their_error() { + let dir = vectorcraft_testkit::temp_dir("save-refusal"); + let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] { + let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let reason = format!("{path}: injected"); + assert!(matches!(&ordinary, EngineError::Other(_))); + assert_eq!(ordinary.to_string(), reason); + if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) { + assert!(matches!(&typed, EngineError::ReadOnlyLocation(_))); + assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}")); + } else { + assert!(matches!(&typed, EngineError::Other(_))); + assert_eq!(typed.to_string(), reason); + } + assert!(!std::path::Path::new(&path).exists()); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn readonly_failed_save_keeps_document_and_options() { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap(); + session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let st = session.doc_mut().unwrap(); + st.path = Some("Poster.svg".into()); + st.format = "svg"; + st.save_options = json!({"decimals": 3}).as_object().unwrap().clone(); + let before = serde_json::to_value(&*st.doc).unwrap(); + let options = st.save_options.clone(); + let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap(); + let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err(); + assert!(matches!(e, EngineError::ReadOnlyLocation(_))); + let st = session.doc().unwrap(); + assert_eq!(st.path.as_deref(), Some("Poster.svg")); + assert_eq!(st.format, "svg"); + assert_eq!(st.save_options, options); + assert!(st.is_dirty()); + assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before); +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index d32fda613d..6ab1608bd7 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -3,6 +3,9 @@ use super::{NodeId, cancel}; /// The text of [`EngineError::Busy`], which ends every error a frontend reports for it. pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish"; +/// Stable prefix for a save refused by the filesystem: choose another destination. +pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION"; + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -20,6 +23,10 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The save's terminal filesystem error refuses writes here. The reason keeps the path and + /// system message. This never means an automation-root refusal or a recovery-copy failure. + #[error("{code}: {0}", code = READ_ONLY_LOCATION)] + ReadOnlyLocation(String), /// The caller asked for the command to stop ([`cancel`]). It changed nothing. #[error("cancelled; nothing was changed")] Cancelled, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..6bc570623d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -370,7 +370,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS; use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; -pub use error::{EngineError, Result, SCRIPT_BUSY}; +pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY}; #[cfg(test)] use history::history_budget_for; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 863c6fdd34..8e7149cad2 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -18,6 +18,25 @@ use std::io::{self, Write as _}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Whether the returned write error refuses this location. Recovery errors must still be shown: +/// their message names copies the user may need, even when their kind is PermissionDenied. +pub fn read_only_location(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::ReadOnlyFilesystem | io::ErrorKind::PermissionDenied) + && !error.get_ref().is_some_and(|e| e.is::()) +} + +/// Marks the existing recovery message without changing its text or the returned error kind. +#[derive(Debug)] +struct RecoveryError(String); + +impl std::fmt::Display for RecoveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RecoveryError {} + /// Write `bytes` to `path` atomically (see the module docs). pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { write_atomic_with(path, |f| f.write_all(bytes)) @@ -102,7 +121,7 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { let _ = std::fs::remove_file(old); let why = format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display()); - return Err(io::Error::new(refused.kind(), why)); + return Err(io::Error::new(refused.kind(), RecoveryError(why))); } let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to)); drop((from, to)); @@ -115,10 +134,11 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { } Ok(()) } - (Err(e), Some(old)) => { - Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display()))) - } - (Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))), + (Err(e), Some(old)) => Err(io::Error::new( + e.kind(), + RecoveryError(format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())), + )), + (Err(e), None) => Err(io::Error::new(e.kind(), RecoveryError(format!("{e} (the new content is kept in {})", tmp.display())))), } } @@ -318,6 +338,24 @@ fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> { mod tests { use super::*; + #[test] + fn readonly_classification_uses_the_terminal_kind_and_excludes_recovery() { + for (kind, expected) in [ + (io::ErrorKind::PermissionDenied, true), + (io::ErrorKind::ReadOnlyFilesystem, true), + (io::ErrorKind::StorageFull, false), + (io::ErrorKind::QuotaExceeded, false), + (io::ErrorKind::NotFound, false), + (io::ErrorKind::Other, false), + ] { + assert_eq!(read_only_location(&io::Error::new(kind, "injected")), expected, "{kind:?}"); + let recovery = io::Error::new(kind, RecoveryError("the new content is kept in a.tmp".into())); + assert!(!read_only_location(&recovery), "recovery: {kind:?}"); + assert_eq!(recovery.kind(), kind); + assert_eq!(recovery.to_string(), "the new content is kept in a.tmp"); + } + } + fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 38545b8292..ed1777b6de 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -55,7 +55,7 @@ use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] -pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; +pub use atomic::{read_only_location, sync_file, write_atomic, write_atomic_with, write_new_with}; pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode}; pub use craft_vectorcraft_format::{ BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 00ff3a2d77..d29161a68b 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -357,6 +357,29 @@ fn save_file_waits_for_the_file_in_the_app() { } } +#[test] +fn readonly_save_file_keeps_the_stable_error_prefix() { + struct Refused(bool); + impl Backend for Refused { + fn call(&mut self, method: &str, params: Value) -> Result { + assert_eq!(method, "app.save"); + assert_eq!(params.get("wait").and_then(Value::as_bool), self.0.then_some(true)); + Err(vectorcraft_engine::EngineError::ReadOnlyLocation("Poster.svg: injected refusal".into()).to_string()) + } + fn has_ui(&self) -> bool { + self.0 + } + fn describe(&self) -> String { + "injected save refusal".into() + } + } + for desktop in [false, true] { + let r = call_tool(&mut Refused(desktop), "save_file", &json!({})); + assert!(r.is_error); + assert_eq!(r.content[0]["text"], "save_file: READ_ONLY_LOCATION: Poster.svg: injected refusal"); + } +} + fn pdf_pages(pdf: &[u8]) -> usize { let mut h = Headless::new(); h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}})) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cc6f5d2262..a3a41c922a 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,8 +15,12 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; mod export; mod open; +mod read_only_save; mod save; +#[cfg(test)] +mod tests_read_only_save; + pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, @@ -29,6 +33,7 @@ pub use open::{ }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; +pub(crate) use save::save_current; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] diff --git a/crates/ui-egui/src/io/read_only_save.rs b/crates/ui-egui/src/io/read_only_save.rs new file mode 100644 index 0000000000..f49603eba2 --- /dev/null +++ b/crates/ui-egui/src/io/read_only_save.rs @@ -0,0 +1,64 @@ +//! The continuation of ordinary File > Save when its own location refuses writes (#6146). +//! It reuses Save As, including extension and overwrite checks, under its own picker entry. + +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, SaveMode, SavePlan}; + +use crate::picks::{self, Entry}; +use crate::{FilePick, VectorcraftApp}; + +pub(super) struct Retry { + path: Option, + pick: FilePick, + params: Value, +} + +impl Retry { + pub(super) fn new(plan: &SavePlan, folder: Option) -> Self { + let name = plan.path.as_deref().map(fileio::file_name).unwrap_or_else(|| plan.name.clone()); + let mut params = json!({ "format": plan.format.id, "options": plan.options }); + if let Some(modified) = plan.modified { + params["modified"] = json!(modified); + } + Self { path: plan.path.clone(), pick: FilePick { name, folder, filters: vec![] }, params } + } + + /// None when this continuation is stale or another picker prevents it. The existing failed-save + /// dialog then stays in charge; there is no queue of deferred Save As requests. + pub(super) fn instead(self, app: &mut VectorcraftApp, uid: u64) -> Option> { + if app.automation_is_acting() || app.services.pick_save.is_none() || app.picks.is_waiting() || super::save::is_web(app) { + return None; + } + let index = app.session.documents().iter().position(|st| st.uid == uid && st.path == self.path)?; + if app.session.active_index() != Some(index) && app.run("document.activate", json!({ "index": index })).is_err() { + return None; + } + let name = &self.pick.name; + let status = format!("Cannot write to the location of “{name}”; choose where to save it"); + app.status(status.clone()); + let save = Rc::new(move |app: &mut VectorcraftApp| { + // An agent can retarget this document while the sheet is open. Its answer must not + // replace that later choice. The picker itself already checks the document's uid. + if !app.session.active().is_some_and(|st| st.uid == uid && st.path == self.path) { + return Err("cancelled".into()); + } + super::save::save_suggesting(app, SaveMode::SaveAs, &self.params, false, Some(&self.pick)) + }); + let again = save.clone(); + let entry = Entry::Call(Rc::new(move |app| { + if let Err(e) = again(app) { + app.status(e); + } + })); + let r = picks::as_own_entry(app, entry, |app| save(app)); + Some(match r { + Err(e) if e == "cancelled" && app.picks.is_waiting() => { + app.status(status); + Ok(json!({ "pending": "fileDialog" })) + } + r => r, + }) + } +} diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..5efe776d64 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -241,6 +241,31 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Save alone offers another destination when its own location refuses writes. +/// Save Changes during Close/Quit continues to use [`save`]. +pub(crate) fn save_current(app: &mut VectorcraftApp, p: &Value) -> Result { + save_current_in(app, p, fileio::documents_dialog_folder()) +} + +/// The same entry with a resolved Documents folder (also permits deterministic host-free tests). +pub(super) fn save_current_in(app: &mut VectorcraftApp, p: &Value, folder: Option) -> Result { + remember_view(app); + let first = plan(app, SaveMode::Save, &acknowledged(p))?; + let own = !app.automation_is_acting() + && !is_web(app) + && p.get("path").is_none_or(Value::is_null) + && first.path.is_some() + && app.session.active().is_some_and(|st| !st.converted && !st.recovered && st.path == first.path && st.format == first.format.id); + if !own { + return save(app, SaveMode::Save, p, true); + } + if let Some(r) = first.path.as_deref().and_then(|path| ask_before_losing(app, path, "file.save", p)) { + return Ok(r); + } + let retry = super::read_only_save::Retry::new(&first, folder); + write_plan(app, first, Some(retry)) +} + /// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the /// engine command's `{path?, format?, options?, svg?}`. /// @@ -253,6 +278,17 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + save_suggesting(app, mode, p, ask_options, None) +} + +/// Reuse Save As with a captured name and folder, without changing the document before writing. +pub(super) fn save_suggesting( + app: &mut VectorcraftApp, + mode: SaveMode, + p: &Value, + ask_options: bool, + suggestion: Option<&FilePick>, +) -> Result { remember_view(app); // Writing over the file the document was read from is asked about here, not refused. let first = plan(app, mode, &acknowledged(p))?; @@ -264,7 +300,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, None); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -273,8 +309,12 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo SaveMode::Template => fileio::template_filters(first.format.id), _ => fileio::save_filters(first.format.id), }); - let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; + let pick = FilePick { + name: suggestion.map_or_else(|| first.name.clone(), |s| s.name.clone()), + folder: suggestion.map_or_else(|| first.folder.clone(), |s| s.folder.clone()), + filters, + }; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { @@ -291,7 +331,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, None) } /// The UI command that runs a save in `mode`. @@ -418,7 +458,7 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, retry: Option) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -444,19 +484,53 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let enabled = app.session.prefs.background_save && target.is_some(); // The work writes the file; the copy makes it the document's own afterwards. let done = job.clone(); + // Capture the caller before background work leaves the request: it must never open a panel + // or error dialog later, when answering_control has been reset. + let by_control = app.automation_is_acting(); + let location_refused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let refused_by_work = location_refused.clone(); + let own_path = target.clone(); + let save_write = app.services.save_write.clone().filter(|_| !is_web(app)); + let save_stream = app.services.save_stream.clone().filter(|_| job.streams() && !is_web(app)); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); let work = move |write: Writer| { - match stream { - Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), - } - .map_err(|e| e.to_string()) + let mut own_refused = false; + let mut observed = |p: &str, r: vectorcraft_engine::Result<()>| { + own_refused = own_path.as_deref() == Some(p) && matches!(&r, Err(EngineError::ReadOnlyLocation(_))); + r + }; + let r = match (save_stream, save_write, stream) { + (Some(stream), _, _) => job.write_streamed(&mut |p, fill| observed(p, stream(p, fill))), + (_, Some(write), _) => job.write(|p, b| observed(p, write(p, b))), + (_, _, Some(stream)) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + _ => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + }; + r.map_err(|e| { + if let EngineError::ReadOnlyLocation(reason) = &e { + refused_by_work.store(own_refused, std::sync::atomic::Ordering::Relaxed); + if !by_control { + return reason.clone(); + } + } + e.to_string() + }) }; let failed = target.clone().unwrap_or_default(); let then = move |app: &mut VectorcraftApp, r: Result| { + if r.is_err() + && location_refused.load(std::sync::atomic::Ordering::Relaxed) + && let Some(retry) = retry + && let Some(r) = retry.instead(app, uid) + { + return r; + } // Not written: the document keeps its file, format and modified state, and the user is told. - let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + let r = r.inspect_err(|e| { + if !by_control { + report_write_error(app, false, &failed, e); + } + })?; // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs new file mode 100644 index 0000000000..8a01003a90 --- /dev/null +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -0,0 +1,266 @@ +//! Injected writers and picker answers: no native panel or filesystem permissions are needed. + +use std::io::ErrorKind; +use std::sync::{Arc, Mutex, mpsc}; + +use serde_json::{Value, json}; +use vectorcraft_engine::{EngineError, READ_ONLY_LOCATION, Session}; + +use super::{fileio, save}; +use crate::control::ControlRequest; +use crate::tests::picks::off_the_ui_thread; +use crate::{Services, VectorcraftApp, background, dialogs, picks}; + +const DOCUMENTS: &str = "/home/artist/Documents"; + +#[derive(Default)] +struct Files { + original: String, + attempts: Vec, + kind: Option, + fail_second: bool, +} + +fn write(files: &Mutex, path: &str) -> vectorcraft_engine::Result<()> { + let mut files = files.lock().unwrap(); + files.attempts.push(path.into()); + if path == files.original || files.fail_second { + let reason = format!("{path}: injected refusal"); + return Err(match files.kind { + Some(ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) => EngineError::ReadOnlyLocation(reason), + _ => EngineError::Other(reason), + }); + } + Ok(()) +} + +fn app(format: &str, kind: ErrorKind) -> (VectorcraftApp, Arc>) { + let files = Arc::new(Mutex::new(Files { original: format!("/locked/Poster.{format}"), kind: Some(kind), ..Default::default() })); + let typed = files.clone(); + let ordinary = files.clone(); + let services = Services { + save_write: Some(Arc::new(move |path, _| write(&typed, path))), + write: Some(Box::new(move |path, _| { + write(&ordinary, path).map_err(|e| match e { + EngineError::ReadOnlyLocation(reason) => reason, + e => e.to_string(), + }) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 40, "height": 40})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 10, "height": 10})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.path = Some(files.lock().unwrap().original.clone()); + st.format = fileio::format(format).unwrap().id; + st.save_options = if format == "svg" { + json!({"decimals": 2, "preserveEditing": false}) + } else { + json!({"pdfCompatible": false, "compress": true, "preview": false}) + } + .as_object() + .unwrap() + .clone(); + (app, files) +} + +fn state(app: &VectorcraftApp) -> (Option, bool, &'static str, Value) { + let st = app.session.doc().unwrap(); + (st.path.clone(), st.is_dirty(), st.format, json!(st.save_options)) +} + +fn poll(app: &mut VectorcraftApp) { + picks::poll(app, &egui::Context::default()); +} + +fn normal_error(app: &VectorcraftApp) { + let d = app.ui.dialog.as_ref().expect("a persistent error dialog"); + assert_eq!(d.kind, dialogs::confirm::MESSAGE); + assert!(d.str("detail").contains("injected refusal")); + assert!(!d.str("detail").contains(READ_ONLY_LOCATION)); + assert!(app.ui.told.is_empty(), "no duplicate error dialog"); +} + +#[test] +fn readonly_fallback_prefills_save_as_and_cancel_keeps_the_failed_save_state() { + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem] { + let (mut app, files) = app("svg", kind); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + let r = save::save_current_in(&mut app, &json!({"modified": null}), Some(DOCUMENTS.into())).unwrap(); + assert_eq!(r["pending"], "fileDialog"); + let requests = shown.requests(); + let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one save panel: {requests:?}") }; + assert_eq!(pick.name, "Poster.svg"); + assert_eq!(pick.folder.as_deref(), Some(DOCUMENTS)); + assert_eq!(pick.filters.first(), crate::FilePick::filters(fileio::save_filters("svg")).first()); + assert!(app.ui.status.contains("choose where to save it")); + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none()); + shown.answer(&[]); + poll(&mut app); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); + assert!(!app.picks.is_waiting()); + } +} + +#[test] +fn readonly_save_as_retargets_only_after_write_and_keeps_options() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + // Hold the second write after the picker answers: the chosen path is not yet the document's. + let (entered, writing) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let released = Mutex::new(released); + let f = files.clone(); + app.services.save_write = Some(Arc::new(move |path, _| { + entered.send(()).unwrap(); + released.lock().unwrap().recv().unwrap(); + write(&f, path) + })); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed saves must use their own writer"))); + app.session.prefs.background_save = true; + shown.answer(&["/home/artist/Documents/Poster.svg"]); + poll(&mut app); + writing.recv().unwrap(); + assert_eq!(state(&app), before); + release.send(()).unwrap(); + background::wait_all(&mut app); + let st = app.session.document_mut(uid).unwrap(); + assert_eq!(st.path.as_deref(), Some("/home/artist/Documents/Poster.svg")); + assert!(!st.is_dirty()); + assert_eq!(st.format, before.2); + assert_eq!(json!(st.save_options), before.3); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/home/artist/Documents/Poster.svg"]); + assert!(shown.requests().is_empty() && app.ui.dialog.is_none()); +} + +#[test] +fn readonly_second_failure_shows_one_dialog_without_reopening_the_picker() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + files.lock().unwrap().fail_second = true; + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.save", json!({})).unwrap(); + shown.answer(&["/elsewhere/Poster.svg"]); + poll(&mut app); + normal_error(&app); + assert_eq!(state(&app), before); + assert!(shown.requests().is_empty() && !app.picks.is_waiting()); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/elsewhere/Poster.svg"]); +} + +#[test] +fn readonly_other_errors_and_explicit_destinations_keep_the_dialog() { + for (command, params, kind) in [ + ("file.save", json!({}), ErrorKind::StorageFull), + ("file.save", json!({}), ErrorKind::NotFound), + ("file.save", json!({}), ErrorKind::Other), + ("file.save", json!({"path": "/locked/Poster.svg"}), ErrorKind::PermissionDenied), + ("file.saveAs", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.saveCopy", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.exportAs", json!({"path": "/locked/Poster.svg", "format": "svg"}), ErrorKind::PermissionDenied), + ] { + let (mut app, _) = app("svg", kind); + let before = state(&app); + // A fallback would return pending, so no inline/native dialog is necessary here. + app.services.pick_save = Some(Box::new(move |_| panic!("{command}: no fallback"))); + assert!(app.run(command, params).is_err(), "{command}"); + normal_error(&app); + assert_eq!(state(&app), before); + } +} + +#[test] +fn readonly_control_returns_the_prefix_and_never_opens_a_panel_even_after_completion() { + for wait in [true, false] { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + app.services.pick_save = Some(Box::new(|_| panic!("automation never picks"))); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + let before = state(&app); + let (tx, rx) = mpsc::channel(); + app = app.with_control(rx); + let (req, reply) = ControlRequest::new("app.save", json!({"wait": wait})); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + let reply = reply.try_recv().unwrap(); + if wait { + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().starts_with("READ_ONLY_LOCATION: "), "{reply}"); + } else { + assert_eq!(reply["result"]["background"], true); + assert!(!app.ui.answering_control); + background::wait_all(&mut app); + assert!(app.ui.status.contains("READ_ONLY_LOCATION: ")); + } + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting()); + } +} + +#[test] +fn readonly_background_completion_resumes_the_original_uid_without_repeating_save() { + for streamed in [false, true] { + let format = if streamed { "vectorcraft" } else { "svg" }; + let (mut app, files) = app(format, ErrorKind::ReadOnlyFilesystem); + let original = files.lock().unwrap().original.clone(); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + if streamed { + let f = files.clone(); + app.services.save_stream = Some(Arc::new(move |path, fill| { + let mut bytes = Vec::new(); + fill(&mut bytes).unwrap(); + write(&f, path) + })); + app.services.save_write = Some(Arc::new(|_, _| panic!("native save must stream"))); + } + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + assert_eq!(app.run("file.save", json!({})).unwrap()["background"], true); + app.run("file.new", json!({"width": 20, "height": 20})).unwrap(); + let other = app.session.doc().unwrap().uid; + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(shown.requests().len(), 1); + // Switch again while the native panel is open: its answer belongs to the original uid. + app.run("document.activate", json!({"index": 1})).unwrap(); + let chosen = format!("/elsewhere/Poster.{format}"); + shown.answer(&[&chosen]); + poll(&mut app); + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(chosen.as_str())); + assert!(!app.session.doc().unwrap().is_dirty()); + assert!(app.session.document_mut(other).unwrap().path.is_none()); + assert_eq!(files.lock().unwrap().attempts, [original, chosen]); + assert!(shown.requests().is_empty()); + } +} + +#[test] +fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.saveAs", json!({})).unwrap_err(); + assert_eq!(shown.requests().len(), 1); + assert!(app.run("file.save", json!({})).is_err()); + normal_error(&app); + assert_eq!(shown.requests().len(), 1); + shown.answer(&[]); + poll(&mut app); + app.ui.dialog = None; + let st = app.session.doc().unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": 0, "uid": st.uid, "then": "close"}))); + assert!(crate::unsaved::confirm(&mut app).is_err()); + normal_error(&app); + assert!(shown.requests().is_empty()); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.doc().unwrap().is_dirty()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..c4d7c21c1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -120,7 +120,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, SaveStreamFn, SaveWriteFn, + Services, WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..0e29e99e12 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -122,7 +122,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), + "file.save" => io::save_current(app, p), "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67c..e21c1f1787 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -40,7 +40,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..080c335d4c 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -188,6 +188,15 @@ pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run a continuation under its own entry: a refused Save's panel must resume Save As, not retry +/// the original Save. Restore the enclosing entry for any work it does afterwards. +pub(crate) fn as_own_entry(app: &mut VectorcraftApp, entry: Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.entry.replace(entry); + let r = f(app); + app.picks.entry = outer; + r +} + /// [`as_entry`] for a panel's or a dialog's button that `f` handles. pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { let f = Rc::new(f); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..3b0bc7caa9 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,6 +43,13 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; +/// A save writer that keeps typed filesystem failures, on either the UI or background thread. +pub type SaveWriteFn = Arc vectorcraft_engine::Result<()> + Send + Sync>; + +/// A typed save writer for native files encoded directly into the output. +pub type SaveStreamFn = + Arc std::io::Result<()>) -> vectorcraft_engine::Result<()> + Send + Sync>; + pub type ExistsFn = Box bool>; pub type Inbox = Arc)>>>; @@ -129,6 +136,10 @@ pub struct Services { /// stream into the file as they are encoded instead of being held whole in memory first. /// Without it they are encoded, then written with `write` / `write_shared`. pub stream_shared: Option, + /// Typed writers for document saves only. Without them the existing string writers are used + /// and failures keep their normal dialog. Downloads never use these callbacks. + pub save_write: Option, + pub save_stream: Option, /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04715474d0..abf5a0e4cb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,5 +1,7 @@ # Control protocol +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + > **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number @@ -111,7 +113,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | @@ -1072,3 +1074,24 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). | | 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). | | 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. | + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df0..c83419fe64 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,5 +1,7 @@ # File format parity +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.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`, @@ -15,6 +17,8 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker keeps the filename, format and options and starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Regression execution and native panel acceptance pending; format scores unchanged. + | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | @@ -339,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..fc003737cc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,7 @@ # Gaps +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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 @@ -337,7 +339,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi selected with Direct Selection (#1839, #1824); Snap to Grid wins over Snap to Pixel (#1840, #1816 unconfirmed); Redo during a drag keeps the drag (#1856, #1719); a template layer dims its images by Dim Images instead of fading the whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until - closed, the document left modified (#1865, #1685). + closed, the document left modified (#1865, #1685). Ordinary File > Save now offers Save As for terminal permission/read-only refusals (#6146), unless another picker is open; recovery errors retain the dialog. Regression execution pending; G11 estimate unchanged. - **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four circles that touch, and merges them with the regions around them (#1691). - **Done (#2523):** Shape Builder Gap Detection shares Live Paint's spatially indexed gap finder, @@ -854,6 +856,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..d8e00fbdb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,7 @@ # VectorCraft MCP server +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to draw, inspect and look at VectorCraft documents. @@ -491,7 +493,7 @@ opens the picker through `run_command` with the remote backend. | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | -| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -9031,3 +9033,24 @@ Path Options' Preview does (#5360). {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{}}} {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{"mode":"path","keepOriginal":true}}} ``` + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b55740614..4f75ce5008 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap: milestones and what's next +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -143,7 +145,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; cancellation/automation isolation; validation pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -190,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..46059e55f9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,7 @@ # Parity with Adobe Illustrator +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -238,7 +240,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | @@ -551,6 +553,8 @@ since 2026-10-01. ## Evidence: what ships today, by area +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, preserving name, format and options. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; execution and native panel acceptance pending. Scores and hours unchanged. + - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -1981,6 +1985,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | @@ -2367,4 +2372,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..9b654cc363 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,5 +1,7 @@ # UI and interaction parity +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -363,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | implemented; validation pending | #6146; `io::tests_read_only_save`; same name/format/options, Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -511,6 +514,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | From 9a77c2ee11750528ce55d3fc5e4129423a30fc09 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Thu, 12 Nov 2026 01:27:25 +0200 Subject: [PATCH 02/31] Save As fallback asks for a changed format's options When the read-only fallback's Save As panel returns a different format, drop the options captured for the original format and open the picked format's normal Save options; an unchanged format keeps its options. Regenerate the file.save entry in the MCP command catalogue. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/io/save.rs | 8 +++++++- crates/ui-egui/src/io/tests_read_only_save.rs | 15 +++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/roadmap.md | 5 +++-- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 5 +++-- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e15244580d..2fc406440f 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 5efe776d64..03a03b4c3a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -315,17 +315,23 @@ pub(super) fn save_suggesting( filters, }; let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; + // Only the read-only retry carries options for the original format through Save As. + // A different format must use its own normal options flow instead. + let changed_retry_format = suggestion.is_some() && fileio::format_for_name(&picked).is_some_and(|f| f.id != first.format.id); // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { o.remove("format"); + if changed_retry_format { + o.remove("options"); + } o.insert("path".into(), json!(picked)); } let chosen = plan(app, mode, &acknowledged(&q))?; if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { return Ok(r); } - if ask && asks_options(mode, chosen.format) { + if (ask || changed_retry_format) && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); let r = ask_format_options(app, mode, chosen.format, &path); crate::dialogs::opened_by_panel(app); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs index 8a01003a90..6568a7e281 100644 --- a/crates/ui-egui/src/io/tests_read_only_save.rs +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -264,3 +264,18 @@ fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { assert_eq!(app.session.documents().len(), 1); assert!(app.session.doc().unwrap().is_dirty()); } + +#[test] +fn readonly_changed_format_asks_for_its_options_before_writing() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + shown.answer(&["/elsewhere/Poster.pdf"]); + poll(&mut app); + let dialog = app.ui.dialog.as_ref().expect("the picked format's Save dialog"); + assert_eq!(dialog.kind, "savePdf"); + assert_eq!(dialog.str("path"), "/elsewhere/Poster.pdf"); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c83419fe64..f8261eef13 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.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) @@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary -Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker keeps the filename, format and options and starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Regression execution and native panel acceptance pending; format scores unchanged. +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker suggests the original filename and format, keeps the original options when the format is unchanged, and opens the chosen format’s Save options when it changes. It starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Focused UI regressions passed; engine regression execution and native panel acceptance pending; format scores unchanged. | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| @@ -343,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f75ce5008..67a5a17a1a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > @@ -145,7 +145,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; cancellation/automation isolation; validation pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -192,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 46059e55f9..fb42bb6a8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > @@ -553,7 +553,7 @@ since 2026-10-01. ## Evidence: what ships today, by area -- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, preserving name, format and options. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; execution and native panel acceptance pending. Scores and hours unchanged. +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged. - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -1985,6 +1985,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b654cc363..dc580549cb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > @@ -365,7 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| -| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | implemented; validation pending | #6146; `io::tests_read_only_save`; same name/format/options, Documents folder, UID-bound continuation; cancellation and automation isolation | +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | focused UI regressions passed; native acceptance pending | #6146; `io::tests_read_only_save`; same name/format suggested; unchanged format keeps options, changed format opens its Save options; Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -514,6 +514,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | From e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:41:24 -0800 Subject: [PATCH 03/31] 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 { 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 { 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 { + 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::>(); + 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 = st.selection.objects.to_vec(); + let paths: Vec = 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)> { + 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 { + path_center(s, p, false) +} + +fn path_center_bottom(s: &mut Session, p: &Value) -> Result { + 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 { + 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 = 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::>(), "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 { (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 { + 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::>(); + let mut bounds: Option = 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)> { 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 { 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 Date: Wed, 11 Nov 2026 17:55:07 -0800 Subject: [PATCH 04/31] =?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::(&text).unwrap(), new); + } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { @@ -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], 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 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 { + 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 = 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) { } } +/// 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) { + 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) { if id != "stylize.scribble" { return; @@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option { 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::>(); + 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 { "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 { "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 { "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 { "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 { 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 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 { 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 { 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 { "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, 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 `` 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 880cdea33cbdbf26c7ec10405e10e025fe0c1d1f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:16:20 -0800 Subject: [PATCH 05/31] Brushes: a built-in Dry Media library of grainy chalk, pencil, crayon and charcoal art brushes Window > Brush Libraries > Dry Media (`dry-media`) holds four art brushes: Chalk Grain, Graphite Pencil, Wax Crayon and Charcoal Dust. Each is 100 pt of filled vector paths generated from a fixed seed with integer steps and plain arithmetic, at most 80 paths and 500 anchors, stretched along the path with Tints colorization. The chalk and crayon art has even-odd gaps cut out of its band. The names, sizes and art are VectorCraft's own. A new i18n test checks that every complete catalog names each built-in brush library. --- crates/engine/src/cmd/brushlib.rs | 165 ++++++++++++++++++ crates/engine/src/tests/brushlib.rs | 140 ++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tests.rs | 14 ++ crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + .../src/panels/tests_brush_libraries.rs | 12 +- docs/gaps.md | 6 +- docs/mcp.md | 5 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 9 +- 35 files changed, 476 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..c6d0b573c4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -173,6 +173,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, + BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media }, ]; /// Built-in library `id`, made once. @@ -446,6 +447,170 @@ fn energy_beams() -> BrushLibrary { built("Energy Beams", Document::new(100.0, 100.0), brushes) } +/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the +/// art is the same on every run and every platform. +struct Grain(u64); + +impl Grain { + /// The next number in 0..1. + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + /// The next number in `a`..`b`. + fn range(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.next() + } +} + +/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each. +fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 { + let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0); + floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t) +} + +/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times +/// `taper(x)`; each edge point is pulled in by up to `rough` of that. +fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect(); + let bottom: Vec = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect(); + pts.extend(bottom); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner +/// in each direction, so its outline never crosses itself. +fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData { + let mut side = |a: f64, b: f64| g.range(a, b); + let pts = [ + (cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)), + (cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)), + ]; + polygon(&pts) +} + +/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what +/// lies under the stroke shows through the holes. +fn holed(body: PathData, holes: Vec, k: f32) -> Node { + let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into(); + n +} + +/// Path `p` filled with gray `k` (1 is black). +fn ink(p: PathData, k: f32) -> Node { + filled(p, Paint::solid(Color::gray(k))) +} + +/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide +/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the +/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the +/// band's roughness margin no sliver touches the band's edge or another sliver. +fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec { + let slot = 88.0 / count as f64; + (0..count) + .map(|i| { + let cx = 6.0 + slot * (i as f64 + 0.5); + let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1)); + let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0)); + let cy = g.range(-1.0, 1.0) * (room - h).max(0.0); + fleck(g, cx, cy, w, h) + }) + .collect() +} + +/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original +/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke +/// color as tints, gaps cut out so the paper shows). +pub(crate) fn dry_media() -> BrushLibrary { + // Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges. + let chalk = { + let mut g = Grain(0x0c4a_1c0a); + let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35); + let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper); + let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, scratches, 0.8)]; + for i in 0..18 { + let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0; + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95); + let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges. + let pencil = { + let mut g = Grain(0x009e_4c11); + let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25)); + let mut parts = vec![ink(core, 1.0)]; + for i in 0..24 { + let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0; + let len = g.range(1.5, 3.0); + let t = g.range(0.12, 0.2); + let slant = g.range(0.1, 0.3); + let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant }; + parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6)); + } + group(parts) + }; + // Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping + // marks along both edges. + let crayon = { + let mut g = Grain(0x00c0_a7e5); + let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5); + let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper); + let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, gaps, 0.9)]; + for i in 0..36 { + let w = g.range(1.0, 2.2); + let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w); + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let h = g.range(0.4, 0.6); + // Clear of the gaps, which stay within 0.9 pt of the middle. + let inner = 0.9 + h; + let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side. + let dust = { + let mut g = Grain(0xc4a2_c0a1); + let mut parts = vec![]; + let mut x0 = 0.0; + while x0 < 100.0 { + let x1 = (x0 + g.range(14.0, 24.0)).min(100.0); + let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 }; + let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3)); + parts.push(ink(piece, 1.0)); + x0 = x1 + g.range(1.5, 3.0); + } + for i in 0..30 { + let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0; + let side = if g.next() < 0.5 { -1.0 } else { 1.0 }; + let s = g.range(0.15, 0.3); + let cy = side * g.range(2.3, 3.5 - s); + let f = fleck(&mut g, cx, cy, s, s); + parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 })); + } + group(parts) + }; + let brushes = vec![ + art("Chalk Grain", chalk, Colorization::Tints), + art("Graphite Pencil", pencil, Colorization::Tints), + art("Wax Crayon", crayon, Colorization::Tints), + art("Charcoal Dust", dust, Colorization::Tints), + ]; + built("Dry Media", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..73b43b1a76 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -124,6 +124,12 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); assert_eq!(libs[5]["count"].as_u64(), Some(4)); + let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"})); + assert_eq!( + dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::>(), + [("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")] + ); + assert_eq!((libs[6]["name"].as_str(), libs[6]["count"].as_u64()), (Some("Dry Media"), Some(4))); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -136,6 +142,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ("scatter-shapes", "scatter"), ("game-trails", "art"), ("energy-beams", "art"), + ("dry-media", "art"), ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); @@ -165,6 +172,137 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast). +fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool { + let mut odd = false; + for (i, a) in pts.iter().enumerate() { + let b = pts[(i + 1) % pts.len()]; + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) { + odd = !odd; + } + } + odd +} + +/// Every anchor of the paths in `nodes` and their descendants. +fn anchors(nodes: &[&vectorcraft_doc::Node]) -> Vec { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| out.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))))); + } + out +} + +/// Dry Media: four art brushes of grainy dry marks (chalk, pencil, crayon, charcoal) with names +/// of their own. Each one's art is a bounded set of filled vector paths 100 pt long that takes the +/// stroke color as tints, with real even-odd gaps in the chalk and the crayon; it draws finite art +/// on curved and short paths, is the same every time it is made, and goes into a document in one +/// undo step, once, applied, and through a saved library unchanged. +#[test] +fn the_dry_media_library_holds_grainy_art_brushes_with_bounded_art() { + use vectorcraft_brush::{ArtScale, Colorization}; + use vectorcraft_geom::FillRule; + let mut s = session(); + let (info, lib) = cmd::brushlib::library(&s, "dry-media").unwrap(); + assert_eq!((info.name.as_str(), info.category, lib.name.as_str()), ("Dry Media", "builtIn", "Dry Media")); + let names: Vec = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert_eq!(names, ["Chalk Grain", "Graphite Pencil", "Wax Crayon", "Charcoal Dust"]); + let mut total = 0; + for (b, (height, gaps)) in lib.brushes.iter().zip([(8.0, true), (2.5, false), (6.0, true), (7.0, false)]) { + let BrushKind::Art(a) = &b.kind else { panic!("{} is not an art brush", b.name) }; + assert_eq!((a.colorization, a.scale, a.width), (Colorization::Tints, ArtScale::Stretch, 100.0), "{}", b.name); + let (mut paths, mut holes) = (0, 0); + a.art.walk(&mut |n| match &n.kind { + NodeKind::Path { path, .. } => { + paths += 1; + assert!(path.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 4), "{}", b.name); + } + NodeKind::Compound { children, rule } => { + assert_eq!(*rule, FillRule::EvenOdd, "{}", b.name); + // The gaps lie inside the band and apart from each other, so each one is a hole. + let body = anchors(&[&*children[0]]); + let boxes: Vec = children[1..].iter().map(|c| c.path_data().unwrap().bounds().unwrap()).collect(); + for (i, c) in children[1..].iter().enumerate() { + assert!(anchors(&[&**c]).iter().all(|p| inside(*p, &body)), "{}: gap {i} leaves the band", b.name); + let r = boxes[i]; + assert!(boxes[..i].iter().all(|o| r.x0 >= o.x1 || o.x0 >= r.x1 || r.y0 >= o.y1 || o.y0 >= r.y1), "{}: gap {i} overlaps", b.name); + } + holes += children.len() - 1; + } + NodeKind::Group { .. } => {} + other => panic!("{}: unexpected {other:?}", b.name), + }); + let pts = anchors(&[&a.art]); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + assert!(paths <= 80 && pts.len() <= 500, "{}: {paths} paths, {} anchors", b.name, pts.len()); + assert_eq!(holes > 0, gaps, "{}: {holes} gaps", b.name); + let r = vectorcraft_geom::Rect::from_points(pts[0], pts[0]); + let r = pts.iter().fold(r, |r, p| r.union_pt(*p)); + assert!(r.x0 >= 0.0 && r.x0 < 0.5 && r.x1 <= 100.0 && r.x1 > 99.5, "{}: {r:?}", b.name); + assert!((r.height() - height).abs() <= height * 0.25 && r.y0 < 0.0 && r.y1 > 0.0, "{}: {r:?}", b.name); + total += pts.len(); + // Finite art on a curve and on a 2 pt path. + for (line, width) in [(vec![(0.0, 0.0), (60.0, -40.0), (140.0, 40.0), (200.0, 0.0)], 1.0), (vec![(0.0, 0.0), (2.0, 0.0)], 3.0)] { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to(line[0]); + if line.len() == 4 { + bp.curve_to(line[1], line[2], line[3]); + } else { + bp.line_to(line[1]); + } + let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), width); + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &bp, &stroke); + let drawn = anchors(&pieces.iter().collect::>()); + assert!(!drawn.is_empty() && drawn.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{} on {line:?}", b.name); + } + } + assert!(total <= 1200, "{total} anchors"); + // Made again, the same bytes; the checksum pins them across runs and platforms. + let bytes = serde_json::to_string(&cmd::brushlib::dry_media().brushes).unwrap(); + assert_eq!(bytes, serde_json::to_string(&lib.brushes).unwrap()); + let fnv = bytes.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + assert_eq!(fnv, 12_859_086_488_513_909_464, "the Dry Media art changed"); + // Names of their own: no default brush's, no other built-in library's. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "dry-media") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::>()) + .collect(); + assert!(names.iter().all(|n| !defaults.contains(&n.as_str()) && !others.contains(n)), "{names:?}"); + + // Added and applied to a path in one undo step, which undo takes back. + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Dry Media", "name": "Wax Crayon", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone()), (vec!["Wax Crayon"], json!([path]))); + assert_eq!((undo_len(&s), stroke_brush(&s).as_deref()), (before.0 + 1, Some("Wax Crayon"))); + assert_eq!(&brush(&s, "Wax Crayon"), lib.brush("Wax Crayon").unwrap()); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (before.1.clone(), None)); + run(&mut s, "edit.redo", json!({})); + // The rest in one more step; then nothing new to add. + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec!["Chalk Grain", "Graphite Pencil", "Charcoal Dust"], vec!["Wax Crayon"])); + assert_eq!(undo_len(&s), before.0 + 2); + assert_eq!( + brushes(&s), + [before.1.clone(), vec!["Wax Crayon".into(), "Chalk Grain".into(), "Graphite Pencil".into(), "Charcoal Dust".into()]].concat() + ); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]).len(), undo_len(&s)), (0, before.0 + 2)); + // Saved as a library and loaded back: the same brushes, in the document's order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Dry" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Dry.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.len(), lib.len()); + for b in &lib.brushes { + assert_eq!(reopened.brush(&b.name), Some(b), "{}", b.name); + } +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..67f69080db 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6022,6 +6022,11 @@ axis Y Axis Y ос Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури + Dry Media Сухи материали + Chalk Grain Тебеширено зърно + Graphite Pencil Графитен молив + Wax Crayon Восъчен пастел + Charcoal Dust Въглен прах Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..9e20a00f2c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6072,6 +6072,11 @@ axis Y Axis Y osa Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici + Dry Media Suhi mediji + Chalk Grain Zrno krede + Graphite Pencil Grafitna olovka + Wax Crayon Voštana bojica + Charcoal Dust Ugljena prašina Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..40f96d0859 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3824,6 +3824,11 @@ digits Malayalam Malaiàlam Scatter Brush Options Opcions del pinzell de dispersió Scatter Graph Tool Eina de gràfic de dispersió Scatter Shapes Formes de dispersió + Dry Media Tècniques seques + Chalk Grain Gra de guix + Graphite Pencil Llapis de grafit + Wax Crayon Cera de colors + Charcoal Dust Pols de carbó Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..78f43ab4a2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6271,6 +6271,11 @@ axis Y Axis osa Y Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary + Dry Media Suché techniky + Chalk Grain Křídové zrno + Graphite Pencil Grafitová tužka + Wax Crayon Vosková pastelka + Charcoal Dust Uhlový prach Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..22fe87c421 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6037,6 +6037,11 @@ axis Y Axis Y-akse Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former + Dry Media Tørre medier + Chalk Grain Kridtkorn + Graphite Pencil Grafitblyant + Wax Crayon Vokskridt + Charcoal Dust Kulstøv Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..87f5876eba 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5854,6 +5854,11 @@ axis Y Axis Y-Achse Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen + Dry Media Trockene Medien + Chalk Grain Kreidekorn + Graphite Pencil Graphitstift + Wax Crayon Wachsmalstift + Charcoal Dust Kohlestaub Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..efab4b490e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6222,6 +6222,11 @@ axis Y Axis Άξονας Y Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς + Dry Media Ξηρά μέσα + Chalk Grain Κόκκος κιμωλίας + Graphite Pencil Μολύβι γραφίτη + Wax Crayon Κηρομπογιά + Charcoal Dust Σκόνη κάρβουνου Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..cbce0ab008 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6213,6 +6213,11 @@ axis Y Axis Eje Y Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión + Dry Media Técnicas secas + Chalk Grain Grano de tiza + Graphite Pencil Lápiz de grafito + Wax Crayon Cera de colores + Charcoal Dust Polvo de carboncillo Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..f4154755b7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6301,6 +6301,11 @@ axis Y Axis Y-akseli Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot + Dry Media Kuivat välineet + Chalk Grain Liitujyvä + Graphite Pencil Grafiittikynä + Wax Crayon Vahaliitu + Charcoal Dust Hiilipöly Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..4be80cbfba 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6266,6 +6266,11 @@ axis Y Axis Axe Y Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion + Dry Media Techniques sèches + Chalk Grain Grain de craie + Graphite Pencil Crayon graphite + Wax Crayon Craie grasse + Charcoal Dust Poussière de fusain Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..d8407860f2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -434,6 +434,11 @@ antiAlias Sharp शार्प Plasma Arc प्लाज़्मा चाप Charge Wave चार्ज तरंग Spark Trail चिंगारी पथ + Dry Media सूखे माध्यम + Chalk Grain चॉक के दाने + Graphite Pencil ग्रेफाइट पेंसिल + Wax Crayon मोम क्रेयॉन + Charcoal Dust चारकोल धूल Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..a24dcf20ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6254,6 +6254,11 @@ axis Y Axis Y tengely Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok + Dry Media Száraz technikák + Chalk Grain Krétaszemcse + Graphite Pencil Grafitceruza + Wax Crayon Zsírkréta + Charcoal Dust Szénpor Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..bf4bf9af40 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6037,6 +6037,11 @@ axis Y Axis Sumbu Y Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar + Dry Media Media kering + Chalk Grain Butiran kapur + Graphite Pencil Pensil grafit + Wax Crayon Krayon lilin + Charcoal Dust Debu arang Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..e1d073b1f2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5888,6 +5888,11 @@ axis Y Axis Asse Y Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione + Dry Media Tecniche secche + Chalk Grain Grana di gesso + Graphite Pencil Matita di grafite + Wax Crayon Pastello a cera + Charcoal Dust Polvere di carboncino Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..babf405210 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5953,6 +5953,11 @@ axis Y Axis Y 軸 Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ + Dry Media ドライメディア + Chalk Grain チョークの粒子 + Graphite Pencil 黒鉛鉛筆 + Wax Crayon ワックスクレヨン + Charcoal Dust 木炭の粉 Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..db7edfa9e7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6222,6 +6222,11 @@ axis Y Axis Y ღერძი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები + Dry Media მშრალი მასალები + Chalk Grain ცარცის მარცვალი + Graphite Pencil გრაფიტის ფანქარი + Wax Crayon ცვილის ფანქარი + Charcoal Dust ნახშირის მტვერი Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..3af02c656c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5849,6 +5849,11 @@ axis Y Axis Y축 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 + Dry Media 건식 재료 + Chalk Grain 분필 입자 + Graphite Pencil 흑연 연필 + Wax Crayon 왁스 크레용 + Charcoal Dust 목탄 가루 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..254a77856b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6088,6 +6088,11 @@ axis Y Axis Y-as Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen + Dry Media Droge media + Chalk Grain Krijtkorrel + Graphite Pencil Grafietpotlood + Wax Crayon Waskrijt + Charcoal Dust Houtskoolstof Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..833c381071 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6125,6 +6125,11 @@ axis Y Axis Oś Y Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone + Dry Media Techniki suche + Chalk Grain Ziarno kredy + Graphite Pencil Ołówek grafitowy + Wax Crayon Kredka woskowa + Charcoal Dust Pył węglowy Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..e39abfa421 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6186,6 +6186,11 @@ axis Y Axis Eixo Y Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão + Dry Media Técnicas Secas + Chalk Grain Grão de Giz + Graphite Pencil Lápis de Grafite + Wax Crayon Giz de Cera + Charcoal Dust Pó de Carvão Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..75a556f585 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5882,6 +5882,11 @@ axis Y Axis Ось Y Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры + Dry Media Сухие материалы + Chalk Grain Меловое зерно + Graphite Pencil Графитный карандаш + Wax Crayon Восковой мелок + Charcoal Dust Угольная пыль Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..0cb3950ca9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6290,6 +6290,11 @@ axis Y Axis Y-axel Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer + Dry Media Torra medier + Chalk Grain Kritkorn + Graphite Pencil Grafitpenna + Wax Crayon Vaxkrita + Charcoal Dust Koldamm Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..47b860d0d1 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,20 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// The Window › Brush Libraries menu, built from the built-in library table, names each library. +#[test] +fn built_in_brush_library_names_are_in_complete_catalogs() { + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&str> = + vectorcraft_engine::cmd::brushlib::BUILTIN.iter().map(|b| b.name).filter(|l| lang.catalog().plain(l).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..6d16b3e5f5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Y ekseni Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri + Dry Media Kuru Teknikler + Chalk Grain Tebeşir Dokusu + Graphite Pencil Grafit Kalem + Wax Crayon Mum Boya + Charcoal Dust Kömür Tozu Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..8ca9966c75 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5868,6 +5868,11 @@ axis Y Axis Вісь Y Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури + Dry Media Сухі матеріали + Chalk Grain Крейдяне зерно + Graphite Pencil Графітний олівець + Wax Crayon Восковий олівець + Charcoal Dust Вугільний пил Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..a07a2f65b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5869,6 +5869,11 @@ axis Y Axis Trục Y Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán + Dry Media Chất liệu khô + Chalk Grain Hạt phấn + Graphite Pencil Bút chì than chì + Wax Crayon Bút sáp màu + Charcoal Dust Bụi than Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..b009ebb080 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6109,6 +6109,11 @@ axis Y Axis Y 轴 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 + Dry Media 干性画材 + Chalk Grain 粉笔颗粒 + Graphite Pencil 石墨铅笔 + Wax Crayon 蜡笔 + Charcoal Dust 炭粉 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..9e51e4c17d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5906,6 +5906,11 @@ axis Y Axis Y 軸 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 + Dry Media 乾性畫材 + Chalk Grain 粉筆顆粒 + Graphite Pencil 石墨鉛筆 + Wax Crayon 蠟筆 + Charcoal Dust 炭粉 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..e57c127319 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -135,9 +135,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), - json!("energy-beams") + json!("energy-beams"), + json!("dry-media") ] ); + let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); + assert_eq!(dry, Some("Dry Media")); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); @@ -151,6 +154,13 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { for t in ["Open Brush Library", "Save Brush Library…"] { assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}"); } + // Dry Media opens in a library panel, and a click on Chalk Grain adds it in one step. + let r = app.run("window.brushLibrary", json!({"library": "Dry Media"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("dry-media"), Some(4))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0); + assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon")); + assert_eq!(undo_len(&app), undo + 1); // Without brushes there is nothing to save. let all: Vec = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); app.run("brush.delete", json!({ "names": all })).unwrap(); diff --git a/docs/gaps.md b/docs/gaps.md index a228181abb..271bce11b4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -631,8 +631,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -650,7 +650,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..501e24d284 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3402,8 +3402,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and +Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). diff --git a/docs/roadmap.md b/docs/roadmap.md index 41066cbb49..92275336aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..7269f440a0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -218,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -836,7 +836,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From f8c234e67030e5b765ccf3ba3ae02e8d79a0c5be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:08:07 -0800 Subject: [PATCH 06/31] Type: Dynamic Text turns point type into area type with Block on Type > Dynamic Text and the Contextual Task Bar now also take point type. Turning it on makes the point type area type with Block on, in the same undo step: - Along the lines, the frame is as wide as the widest line as typed with its paragraph's indents, measured with the word gap, so that line keeps its size and stays where it was across the page by its own alignment. The other lines are set to that width. When negative indents take more than that line's width, the frame is as wide as the line, placed so the line keeps its aligned point, and Block makes the line larger. - The frame starts at the top of the text and grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it, so nothing overflows. - The new frame takes the default Area Type Options and keeps only the gaps; options kept from an earlier frame do not carry over. - Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when `on` is false. Without `on`, point type counts as off, and the Type menu item is unchecked when point type comes first in the selection. A gap given with nothing it can apply to is an error. Block now subtracts a list level's indent from the room it sets each line to, as the layout does. Before, a nested list item was first set too large for its line and then made smaller on a later pass. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/typecmd.rs | 1 + crates/engine/src/cmd/typecmd/block.rs | 35 ++- crates/engine/src/cmd/typecmd/block_tests.rs | 252 ++++++++++++++++++- crates/engine/src/cmd/typemenu.rs | 173 +++++++++++-- crates/engine/src/tests/dynamic_text.rs | 10 +- crates/ui-egui/src/canvas/task_bar.rs | 4 +- crates/ui-egui/src/menus/state.rs | 6 +- crates/ui-egui/src/menus/tests.rs | 12 +- crates/ui-egui/src/tests/taskbar.rs | 16 ++ docs/mcp.md | 19 +- docs/target-app-parity.md | 1 + 11 files changed, 487 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 33dad39d93..d492917bfe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod area_tests; mod block; +pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs index 06453f4983..c10abbc965 100644 --- a/crates/engine/src/cmd/typecmd/block.rs +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection out } +/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as +/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`] +/// measures them, and a margin of under half a size step so that the line is not made a step +/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative +/// indents take more than its width needs its own width. None when no line has a width. +pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> { + let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?; + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(vectorcraft_text::FontDb::global(), t); + let sizes = first_sizes(&t.runs, &lines); + lines + .iter() + .zip(&sizes) + .filter_map(|(l, size)| { + let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; + let size = size * points; + if l.content == l.start || !(size.is_finite() && size > 0.0) { + return None; + } + let para = t.para_at(l.para); + let first = if l.para_start { para.first_line_indent } else { 0.0 }; + let indents = para.laid_out_left_indent() + para.right_indent + first; + // Negative indents that take more than the line's width leave the line no room at its + // size; the frame is then as wide as the line itself, and Block makes it larger. + let room = if width + indents > 1e-9 { width + indents } else { width }; + // A tenth of a point more size widens the line by 0.1 * width / size. + Some((room + 0.04 * width / size, l.start, l.para)) + }) + .filter(|r| r.0.is_finite() && r.0 > 1e-9) + .reduce(|a, b| if b.0 > a.0 { b } else { a }) +} + /// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing /// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down /// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its @@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; let para = t.para_at(l.para); let first = if l.para_start { para.first_line_indent } else { 0.0 }; - let avail = cell - para.left_indent - para.right_indent - first; + let avail = cell - para.laid_out_left_indent() - para.right_indent - first; let size = size * points; if !(size.is_finite() && size > 0.0) { return None; diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index ec458a0c9b..0354a630b3 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() { assert!(!text(&s, id).dynamic_text); let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [point]})).unwrap(); + // Off leaves point type as it is, with no undo step, and a gap needs it turned on. + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point])); + let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + assert_eq!(undo_len(&s), n); + assert!(matches!(text(&s, point).kind, TextKind::Point)); + let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [shape]})).unwrap(); let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string(); assert!(e.contains("select type on a path or area type"), "{e}"); - // With point type selected too, only the area type changes. + // With point type selected too, both turn on, the point type as area type with Block on. s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + let n = undo_len(&s); let r = s.execute("type.dynamicText", &json!({})).unwrap(); - assert_eq!(r["ids"], json!([id])); - assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); + assert_eq!(r["ids"], json!([id, point])); + assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text); + assert!(matches!(text(&s, point).kind, TextKind::Area { .. })); + assert_eq!(undo_len(&s), n + 1); // A gap with only type on a path selected is an error, and nothing changes. let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap(); let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap(); @@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() { assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); } +/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected. +fn point(s: &mut Session, text: &str, extra: Value) -> u64 { + let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +/// Where the first glyph of the laid-out line starting at byte `start` is in the document. +fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there"); + t.xf * lay.glyphs[l.glyph_start].origin +} + +#[test] +fn point_type_becomes_area_type_with_block_at_its_widest_line() { + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short\nA much longer line", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + let before = line_start(&text(&s, id), 6); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id]))); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps"); + assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}"); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size"); + assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0)); + // Across, the widest line stays where it was; the short line above it grew, so it moved down. + let after = line_start(&t, 6); + assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}"); + assert!(after.y > before.y + 12.0, "{justify}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line"); + // Edits fit it again as area type; undo brings the point type back as it was. + s.execute("edit.undo", &json!({})).unwrap(); + let back = text(&s, id); + assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}"); + } +} + +#[test] +fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() { + let mut s = session(); + // The gaps apply to the frame point type becomes. + let id = point(&mut s, "one\ntwo words here", json!({})); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap(); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0))); + let t = text(&s, id); + assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0)); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Indents: the frame takes them in, so the widest line keeps its size. + let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300})); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap(); + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + assert_eq!(size_at(&t, 5), 12.0); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A nested list item: the frame takes in the level's indent too. + let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "nested list"); + assert!(size_at(&t, 0) > 12.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Mixed alignments: the widest line keeps its size and its place. + let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400})); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) { + let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() }; + t.set_paragraph_styles(vec![t.para.clone(), right]); + } + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "mixed alignments"); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A right-to-left list, whose indents the layout mirrors. + let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap(); + let widest = "קצר\n".len(); + let before = line_start(&text(&s, id), widest); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, widest), 12.0, "right-to-left list"); + assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest)); + // Point type that was area type with its own options: the new frame takes the defaults but the + // gaps. + let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500})); + s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.areaOptions", + &json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}), + ) + .unwrap(); + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let before = line_start(&text(&s, id), 4); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() }; + assert_eq!(t.area, defaults); + assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame"); + assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4)); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Vertical type: the frame is as tall as the longest column and grows to the left. + let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true})); + let before = line_start(&text(&s, id), "縦書き\n".len()); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert_eq!(size_at(&t, "縦書き\n".len()), 12.0); + let after = line_start(&t, "縦書き\n".len()); + assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}"); + assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + +#[test] +fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() { + // Block makes the line larger; it keeps its start, center or end by its alignment. + let ends = |t: &TextObject| { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &lay.lines[0]; + ((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x) + }; + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap(); + let (b0, b1) = ends(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "{justify}"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}"); + assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger"); + let (a0, a1) = ends(&t); + let kept = match justify { + "left" => (a0, b0), + "center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0), + _ => (a1, b1), + }; + assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}"); + } +} + +#[test] +fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() { + let mut s = session(); + let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({})); + // A wrap object above the type, across the top of the frame it becomes. + let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert!(!t.wrap.is_empty(), "the new area type takes the wrap object"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap"); + // A wrap object deeper than any size Block sets, across the whole frame: the lines go below it. + let id = point(&mut s, "Below", json!({"x": 1000, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object"); + // An inverted wrap object far below the type holds the lines inside it: the frame reaches it. + let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object"); +} + +#[test] +fn point_type_with_nothing_to_fit_is_left_as_it_is() { + let mut s = session(); + for blank in ["", " ", "\n\n"] { + let id = point(&mut s, blank, json!({})); + let before = text(&s, id); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], json!(true)); + assert_eq!(undo_len(&s), n, "{blank:?}: no undo step"); + assert_eq!(text(&s, id), before, "{blank:?}"); + // A gap needs type it can apply to. + let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{blank:?}: {e}"); + } + // A line far shorter than the widest one stops at the size limit, and the frame still holds it. + let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6})); + s.execute("type.dynamicText", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(size_at(&t, 0), 1296.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + #[test] fn long_text_fits_in_a_bounded_number_of_layouts() { let mut s = session(); @@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() { // pass and the caches), plus the reply's own. let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4; assert!(used <= bound, "{used} layouts"); + // Long point type: measured, fitted in its frame and fitted again at its depth, with the + // layouts that place and measure the frame. + let id = point(&mut s, &story, json!({})); + let before = vectorcraft_text::test_fonts::layouts_on_this_thread(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before; + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8; + assert!(used <= bound, "point type: {used} layouts"); } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index bb0e426c9b..78dab584b3 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::{area_text, block_gaps, refresh_bounds}; +use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, switch_or, targets, text_param, @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", has_dynamic_type, dynamic_text ), @@ -325,13 +325,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result { // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); let slack = size * 0.5 + trailing; - // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. - let (sl, sr) = match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), - Justify::Right | Justify::JustifyRight => (slack, 0.0), - Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), - _ => (0.0, slack), - }; + let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack); let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); @@ -341,6 +335,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } +/// How `slack` added to point type's lines in a frame is shared before and after them: where the +/// alignment leaves room, so the text doesn't move or rewrap. +fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) { + match justify { + Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), + Justify::Right | Justify::JustifyRight => (slack, 0.0), + Justify::Auto if rtl => (slack, 0.0), + _ => (0.0, slack), + } +} + +/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type +/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as +/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own +/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows. +/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the +/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type +/// takes, in the type's own space, which the fit and the depth follow. When negative indents take +/// more than the widest line's width, the frame is as wide as that line and placed so that the +/// line keeps its aligned point. None when no line has a +/// width (empty text, or only spaces and breaks), which Block leaves at its size. +fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option { + let db = vectorcraft_text::FontDb::global(); + // Options point type kept from a frame it had before (columns, inset, alignment, first + // baseline) do not carry over to the new frame. + let mut t = t.clone(); + let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap); + t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() }; + t.wrap = wrap.to_vec(); + let t = &t; + // The room is measured as Block sets the lines in a frame, with the word gap. + let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() }; + let (room, widest, para_index) = needed_room(&as_block)?; + let lay = vectorcraft_text::layout(db, t); + let first = lay.lines.first()?; + let line = lay.lines.iter().find(|l| l.start == widest)?; + // Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds. + let para = t.para_at(para_index); + let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n'); + let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 }; + // A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them. + let mirror = line.rtl && !t.vertical && para.list.is_some(); + let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) }; + let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right); + let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min); + let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max); + if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) { + return None; + } + // Negative indents can take more than the line's width (hi below lo); the frame is then wider + // than the line with its indents, and the same split of the difference keeps the line's + // aligned point (its start, center or end) where it was when Block makes the line fill it. + let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo)); + let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0))); + let start = frame(bottom - top); + let mut out = t.clone(); + out.kind = TextKind::Area { frame: shapes::rectangle(start) }; + out.dynamic_text = true; + refresh_bounds(&mut out); + // The lines fitted in one cell from the frame's top deep enough for all of them (every line at + // the largest size Block sets), around any wrap shapes, give the depth the frame takes. + let mut probe = out.clone(); + let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12); + let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points; + // Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also + // reaches as far again as the farthest one reaches past the frame's top. + let inv = lay.line_xf.inverse(); + let deep = deep + + wrap + .iter() + .filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs())) + .filter(|d| d.is_finite()) + .fold(0.0, f64::max); + probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) }; + if !wrap.is_empty() { + refresh_bounds(&mut probe); + } + let laid = vectorcraft_text::layout(db, &probe); + if laid.overflow { + return Some(out); + } + let mut grown = start; + for l in &laid.lines { + for y in [l.baseline - l.ascent, l.baseline + l.descent] { + grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y)); + } + } + // Only the depth grows (for vertical type, the columns run on to the left), with a hundredth + // of a point to spare. + if t.vertical { + (grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01); + } else { + (grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01); + } + if grown.is_finite() && grown != start { + out.kind = TextKind::Area { frame: shapes::rectangle(grown) }; + refresh_bounds(&mut out); + } + Some(out) +} + /// (run, byte within the run) for a byte offset into the plain text. fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> { let mut off = 0; @@ -542,14 +637,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { path_text(n).or_else(|| area_text(n)) } -/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it -/// in a thread. +/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into +/// area type with Block on. +fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> { + match n.map(|n| &n.kind) { + Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t), + _ => dynamic_type(n), + } +} + +/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type, +/// none of it in a thread. fn has_dynamic_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 = st.selection.objects.to_vec(); - let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect(); if types.is_empty() { return Err("select type on a path or area type".into()); } @@ -575,32 +679,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { /// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on /// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in -/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn -/// at ([`bake_dynamic_text`]). +/// one undo step, and none when nothing changes. Turning it on also turns the selected point type +/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was +/// drawn at ([`bake_dynamic_text`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result { const C: &str = "type.dynamicText"; let (word_gap, line_gap) = block_gaps(p, C)?; - let (ids, on, planned) = { + let (ids, on, planned, converted) = { let d = &s.doc()?.doc; - let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect(); + let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect(); let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?; if let Some(why) = threaded_refusal(d, &ids) { return Err(bad(C, why)); } let gap_given = word_gap.is_some() || line_gap.is_some(); - if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) { + let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; + // Point type with a line to fit, which turning it on makes area type. + let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some()); + if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) { return Err(bad(C, "wordGap and lineGap need area type in the selection")); } - let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; // Each object as the command leaves it, and only those it changes. let mut planned: Vec<(NodeId, TextObject)> = vec![]; + // The point type that becomes area type, with the gaps applied, by id. + let mut converted: Vec<(NodeId, TextObject)> = vec![]; for id in &ids { - let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let Some(t) = dynamic_target(d.node(*id)) else { continue }; let mut after = t.clone(); - if matches!(t.kind, TextKind::Area { .. }) { + if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) { after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); } + if matches!(t.kind, TextKind::Point) { + // Point type has no Dynamic Text of its own: on, it becomes area type with Block + // on; off, or with no line to fit, it stays as it is. + if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() { + planned.push((*id, block)); + converted.push((*id, after)); + } + continue; + } match (on, t.dynamic_text) { (true, true) => refresh_bounds(&mut after), (true, false) => { @@ -620,7 +738,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { planned.push((*id, after)); } } - (ids, on, planned) + (ids, on, planned, converted) }; if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { @@ -629,6 +747,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { **t = after.clone(); } } + if converted.is_empty() { + return Ok(()); + } + // The new area type takes the Text Wrap shapes above it; one that has any is fitted + // again around them, with the depth that holds every line. + for id in vectorcraft_doc::text::resolve_wrap(None, d) { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap)); + match block { + Some(block) => **t = block, + None => refresh_bounds(t), + } + } Ok(()) })?; } diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index ae1a72fa08..6b221590b0 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() { assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on")); assert_eq!(undo_len(&s), before); assert!(!text(&s, id).dynamic_text); - // Point type alone: refused (area type takes Block, `typecmd::block_tests`). let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [point]})).unwrap(); - assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type")); let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [empty]})).unwrap(); assert!(s.execute("type.dynamicText", &json!({})).is_err()); - // With point type selected too, only the type on a path changes. + // With point type selected too, both change in one undo step: the point type becomes area + // type with Block on (`typecmd::block_tests`). let before = undo_len(&s); s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap(); let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap(); - assert_eq!(r["ids"], json!([id.0])); - assert!(!text(&s, NodeId(point)).dynamic_text); + assert_eq!(r["ids"], json!([point, id.0])); + assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text); assert_eq!(undo_len(&s), before + 1); } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index a7a7f0a45c..77e21e49bc 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,8 +153,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), } - // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; - // left out for point type and type in a thread + // Dynamic Text turns on or off for type on a path and area type (Block), and turns point type into area + // type with Block on, as the Type menu's item does; left out for type in a thread // (). if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ae7b7bb891..8b731c95b7 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -41,9 +41,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical") } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), - // Dynamic Text of the first selected type on a path or area type, which the item turns the - // other way. - "type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text), + // Dynamic Text of the first selected type, which the item turns the other way (point type + // counts as off: the item turns it into area type with Block on). + "type.dynamicText" => selected_text(|_| true).is_some_and(|t| t.dynamic_text && !matches!(t.kind, vectorcraft_engine::doc::TextKind::Point)), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3d1577e1be..f4dff96dd8 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } -/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that -/// are in no thread, and is checked while the selected type has it on. +/// Type › Dynamic Text runs `type.dynamicText`, is enabled for point type and for type on a path +/// and area type that are in no thread, and is checked while the selected type has it on. #[test] fn dynamic_text_is_a_checked_type_menu_item() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -334,7 +334,8 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert!(!enabled(&app, id)); let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [point]})).unwrap(); - assert!(!enabled(&app, id), "point type"); + assert!(enabled(&app, id), "point type, which it turns into area type with Block on"); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type"); let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [t]})).unwrap(); @@ -356,6 +357,11 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert_eq!(checked(&app, id, &Value::Null), Some(false)); app.run(id, json!({})).unwrap(); assert_eq!(checked(&app, id, &Value::Null), Some(true)); + // Point type first: unchecked, as the item turns it on. + app.run("select.set", json!({"ids": [point, area]})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type first"); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true), "both on"); } /// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d42be49ff9..47015639c8 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -232,6 +232,22 @@ fn area_type_offers_dynamic_text() { assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Point type offers Dynamic Text beside Convert To Area Type, which turns it into area type with +/// Block on; the bar then offers what area type does. +#[test] +fn point_type_offers_dynamic_text() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Fit\nme in a frame"})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Area Type") && has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(matches!(kind(&app, t), NodeKind::Text(ref t) if t.dynamic_text && matches!(t.kind, TextKind::Area { .. }))); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}"); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..2c23a0b0f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6923,7 +6923,7 @@ lines. The rest are VectorCraft's own rules: - The layout sets every paragraph with `wordGap` as its Desired word spacing (Justification…), and its Minimum or Maximum word spacing moves out to `wordGap` when `wordGap` lies outside it. The paragraphs keep their own Justification… values, which apply again when Block is off. Each character's leading is its size plus `lineGap`. A - gap given with no area type in the selection is an error. + gap given with no area type in the selection, and no point type that it turns into area type, is an error. - The sizes are written into the characters, so every export and the Character panel see them. After every edit to the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at @@ -6932,14 +6932,27 @@ lines. The rest are VectorCraft's own rules: - An empty line, or a line of spaces, keeps its size. At 1,296 pt a line can be narrower than the frame; at 0.1 pt it can still wrap. A frame with no room along its lines changes nothing. Right-to-left lines are fitted as left-to-right ones are. -- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Point type in the selection - is left as it is. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` +- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` refuses it too); threading a frame turns Block off, keeping its sizes. Without `on`, it toggles from the first object's setting, or turns on when a gap is given. A value that is not a boolean, or a gap outside its range, is an error. When nothing changes, no undo step is recorded. - Saved and opened, the native file keeps Block on with its gaps and the sizes in the characters; with Block off it keeps the sizes. +Turning it on also takes point type, in the same undo step (the Contextual Task Bar offers it beside Convert To Area +Type). [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) +applies Dynamic Text, Block among its shapes, to existing text. VectorCraft's own rules for point type: + +- It becomes area type with Block on and the default Area Type Options, keeping only its gaps: options it kept from + an earlier frame (columns, inset, vertical alignment, first baseline) do not carry over. Along its lines, the frame + is as wide as the widest line as typed with its paragraph's indents, so that line keeps its size and stays where it + was across the page by its own alignment; the other lines are set to that width. When negative indents take more + than the widest line's width, the frame is as wide as that line, and Block makes it larger. The frame starts at the + top of the text and then grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap + objects above it, so nothing overflows. +- The gaps given apply to its frame. Point type with no line to fit (empty, or only spaces and breaks) is left as it + is, and so is point type when `on` is false. Without `on`, point type counts as off. + The reply is `{on, ids, fitScale, wordGap, lineGap}` (`fitScale` as for type on a path, 1 when the first object is area type; the gaps are the first area type's). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..3ef8845ca8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1189,6 +1189,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on point type (Type › Dynamic Text, the Contextual Task Bar; [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) applies it to existing text) turns it into area type with Block on. VectorCraft's own rules: the frame is as wide as the widest line with its indents, so that line keeps its size and place across the page, and it grows in depth to hold every fitted line (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. From 9c5a2fa2b87309916d4331afdd3f1456cb67cbcf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:37:18 -0800 Subject: [PATCH 07/31] 3D: a solid in a transparent gradient keeps the shared face order With Extrude & Bevel, Extrude & Bevel (Classic) or Inflate on a group or an object with fills and strokes, a solid whose gradient has transparent stops carries an opacity mask, and it was drawn as one whole surface in the order of the members. It could cover a solid in front of it or hide behind one it is in front of. Its faces now take their places in the one back-to-front order of every solid's faces, in runs that each carry the solid's opacity mask. A run splits only where a face drawn after it overlaps the next face. --- crates/effects/src/revolve.rs | 12 ++-- crates/effects/src/solids.rs | 88 ++++++++++++++++++++++++++---- crates/effects/src/solids/tests.rs | 80 ++++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 25 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..9b85185e2f 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -20,7 +20,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20; /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. -const MAX_OVERLAP_TESTS: usize = 1 << 22; +pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22; /// The width of the stroke around each face that covers the seams between faces. const SEAM_WIDTH: f64 = 0.75; @@ -576,14 +576,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc)>, le /// What a face can paint, to tell whether two faces overlap: the convex hull of the points and /// control points of its outline, which holds the outline, and the hull's box grown by half the /// seam stroke. -struct Coverage { - bounds: Rect, +pub(crate) struct Coverage { + pub(crate) bounds: Rect, hull: Vec, } impl Coverage { /// `None` for a face with no outline and no box. - fn of(face: &Node) -> Option { + pub(crate) fn of(face: &Node) -> Option { let points: Vec = match face.path_data() { Some(path) => path .to_bezpath() @@ -611,7 +611,7 @@ impl Coverage { /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and /// sometimes near a corner when they don't. - fn meets(&self, other: &Coverage) -> bool { + pub(crate) fn meets(&self, other: &Coverage) -> bool { fn normals(hull: &[Point]) -> impl Iterator + '_ { (0..hull.len()).filter_map(|i| { let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); @@ -658,7 +658,7 @@ fn convex_hull(mut points: Vec) -> Vec { } /// Do boxes `a` and `b` overlap with some area? -fn overlaps(a: Rect, b: Rect) -> bool { +pub(crate) fn overlaps(a: Rect, b: Rect) -> bool { a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..4744202dd0 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -686,10 +686,11 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, /// each in its own paint. When every one is opaque with normal blending and no colored light makes -/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where -/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with -/// caps, a shape's fill walls before the faces of its covering strokes that overlap them: -/// [`draw_order`]); +/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as +/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's +/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask +/// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it @@ -841,19 +842,20 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); let (mut placed, mut faces) = (vec![], vec![]); - for (depths, s, _) in surfaces { + for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { placed.extend(depths); - faces.extend(children.into_iter().map(Some)); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); } } let sealed = match fx.id.as_str() { @@ -862,7 +864,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option extrude_options(&p).cap, }; let order = draw_order(&placed, sealed); - Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))]) + let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); + let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) }; + Some(vec![Arc::new(Node::group(n.id, children))]) +} + +/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces +/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order +/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a +/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does +/// the surface split, where one group can't be both behind and in front of another face. +fn in_runs(id: NodeId, faces: Vec<(usize, Arc)>, masks: &[Option>]) -> Vec> { + use crate::revolve::{Coverage, overlaps}; + struct Run { + surface: Option, + bounds: Rect, + faces: Vec>, + cover: Vec, + } + let mut runs: Vec = vec![]; + let mut last_run = vec![None; masks.len()]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = crate::revolve::MAX_OVERLAP_TESTS; + for (surface, face) in faces { + let Some(cover) = Coverage::of(&face) else { + // A face with no outline and no box overlaps nothing. + runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] }); + continue; + }; + if masks.get(surface).is_none_or(Option::is_none) { + runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(surface) { + *last = Some(runs.len()); + } + runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + } + } + } + runs.into_iter() + .filter_map(|mut r| { + let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() }; + let mut group = Node::group(id, r.faces); + group.extras_mut().mask = Some(mask); + Some(Arc::new(group)) + }) + .collect() } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 248473f428..a79f209381 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() { } /// With a light that is not white, a fill in a gradient with transparent stops is drawn with an -/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their -/// own, and the mask stays on the fill's. Under a white light the same object is one surface. +/// opacity mask: its faces still take their places in the one order of the fill's and the +/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the +/// gradient itself and no mask. #[test] -fn a_masked_surface_under_a_colored_light_stays_whole() { +fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let mut half = GradientStop::new(1.0, RED); half.opacity = 0.5; let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half])); - for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { + for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] { let mut n = stroked_rect(100.0); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { f.paint = Paint::Gradient(Box::new(gradient.clone())); @@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() { n.appearance.effects.push(fx(EXTRUDE, lights.clone())); let art = solid_art(&n).unwrap(); let drawn = art.children().unwrap(); - assert_eq!(drawn.len(), surfaces, "{lights}"); - assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}"); + assert_eq!(drawn.len(), 1, "{lights}: one order of faces"); + let runs: Vec<&Arc> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect(); + assert_eq!(!runs.is_empty(), masked, "{lights}"); + assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces"); } } @@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() { } let art = solid_art(&n).unwrap(); let surface = &art.children().unwrap()[0]; - assert_eq!(surface.extras().mask.is_some(), masked, "{end}"); + let mut has_mask = false; + surface.walk(&mut |c| has_mask |= c.extras().mask.is_some()); + assert_eq!(has_mask, masked, "{end}"); let mut opaque = true; surface.walk(&mut |c| { for item in &c.appearance.items { @@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() { } } +/// A group member in a gradient with transparent stops keeps the shared back-to-front order of +/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's +/// faces are drawn between them where they are in front of some and behind others. Here a square +/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square +/// and its front cap is in front of the square's walls. +#[test] +fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| { + let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)]; + let mut points = corners.to_vec(); + if !clockwise { + points.reverse(); + } + b.move_to(points[0]); + for p in &points[1..] { + b.line_to(*p); + } + b.close_path(); + }; + let art = |id: &str, params: &Value, end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end])))); + let mut b = BezPath::new(); + square(&mut b, 100.0, 300.0, true); + square(&mut b, 150.0, 250.0, false); + let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut b = BezPath::new(); + square(&mut b, 185.0, 215.0, true); + let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]); + g.appearance.effects.push(fx(id, params.clone())); + solid_art(&g).unwrap_or_else(|| panic!("{id}")) + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] { + assert!(!order(&art(id, ¶ms, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask"); + let masked = order(&art(id, ¶ms, 0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}"); + } +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); From 2bb8cd0b92785fd287dc2bdada0cfd921b247f43 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:04:53 -0800 Subject: [PATCH 08/31] =?UTF-8?q?Swatch=20libraries:=20Patterns=20?= =?UTF-8?q?=E2=80=BA=20Optical=20Effects=20adds=20wave,=20moir=C3=A9=20and?= =?UTF-8?q?=20op-art=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Patterns and the Swatches panel's library menu gain Optical Effects (`optical-effects`), a built-in pattern library of eight black tiles drawn in code: Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses. The names and designs are VectorCraft's own. Each tile is 32 to 48 pt, one period of its design, with fixed sample counts (at most 600 paths and 20,000 segments), so a band that leaves one edge comes back at the same place on the opposite edge, and rings and lenses that cross an edge are kept whole for the tiling to draw on the next tile. Clicking a swatch adds its pattern and swatch once and applies it, as with Geometric Patterns (`swatch.library.add`). The menu label has a row in every catalog; docs/mcp.md, the parity doc and ROADMAP name the library. --- ROADMAP.md | 1 + crates/engine/src/cmd/patternlib.rs | 254 ++++++++++++++++++ crates/engine/src/tests/swatchlib.rs | 45 +++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + .../ui-egui/src/panels/library_panel/tests.rs | 3 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 2 +- 33 files changed, 333 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1f75b2735..af46cb5a4e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). - 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. +- 2026-11-11: Pattern libraries: Patterns › Optical Effects, a third built-in pattern library, of VectorCraft's own designs, holds eight black wave, moiré and op-art tiles (Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses) of 32 to 48 pt, each drawn the same way every time; each tile is one period of its design, and art that reaches past a tile edge continues on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 8fd2a0ea5e..f69e4b889e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary { pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, + BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects }, ]; /// The library category of the built-in pattern libraries. @@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) { }); } +/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced. +fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> { + (0..=n) + .map(|i| { + let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n); + (t, f(t)) + }) + .collect() +} + +/// The closed band between two curves drawn from the same side to the same side: `upper` forward, +/// then `lower` back. +fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData { + let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect(); + polygon(&points) +} + +/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`. +fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath { + const CORNERS: u32 = 72; + let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU }; + let points: Vec = (0..CORNERS) + .map(|i| { + let a = turn * f64::from(i) / f64::from(CORNERS); + Point::new(cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + SubPath::polyline(&points, true) +} + +/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one +/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives +/// a disk. +fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData { + let mut subpaths = vec![circle(cx, cy, outer, false)]; + if inner > 0.0 { + subpaths.push(circle(cx, cy, inner, true)); + } + PathData::new(subpaths) +} + +/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that +/// meet at its tips, turning the other way with `reverse`. +fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath { + const STEPS: u32 = 32; + let at = |i: u32, side: f64| { + let t = f64::from(i) / f64::from(STEPS); + Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin()) + }; + let mut points: Vec = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect(); + if reverse { + points.reverse(); + } + SubPath::polyline(&points, true) +} + +/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a +/// clear background. Each tile is one period of its design, so a band that leaves one edge comes +/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the +/// tiling draws the part outside the tile on the next tile. +fn optical_effects(d: &mut Document) { + use std::f64::consts::TAU; + // Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile. + let bands = (0..3u32) + .map(|k| { + let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin(); + band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5)) + }) + .collect(); + add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands); + // Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one + // above it, so the ninth leads into the first wave of the next tile down. + let lines = (0..9u32) + .map(|k| { + let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0); + let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin(); + band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6)) + }) + .collect(); + add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines); + // Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its + // right. The two sets cross, and the outer rings reach into the next tile across. + let rings = (1..=7u32) + .map(|k| (24.0, 3.0 * f64::from(k))) + .chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k)))) + .map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6)) + .collect(); + add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings); + // Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's + // starting with the narrower one. The corner's rings are the same rings at all four corners. + let mut rings = vec![]; + for (c, first) in [(0.0, 0), (20.0, 1)] { + let mut r = 0.0; + for k in 0..5 { + let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 }; + rings.push(ring(c, c, r, r + width)); + r += width + 1.2; + } + } + add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings); + // An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and + // whose rows by one across it, each edge drawn through five points. + let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin()); + let cells = (0..8u32) + .flat_map(|row| (0..8u32).map(move |col| (col, row))) + .filter(|(col, row)| (col + row) % 2 == 0) + .map(|(col, row)| { + let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row)); + let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)]; + let points: Vec<(f64, f64)> = corners + .windows(2) + .flat_map(|e| { + let (a, b) = (e[0], e[1]); + (0..4u32).map(move |i| { + let t = f64::from(i) / 4.0; + warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }) + }) + .collect(); + polygon(&points) + }) + .collect(); + add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells); + // Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile, + // each a sixth of a wave on from the one to its left. + let stripes = (0..6u32) + .map(|k| { + let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0); + let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos(); + let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() }; + band(&side(-1.0), &side(1.0)) + }) + .collect(); + add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes); + // Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands + // rise and fall from band to band. + let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)] + .into_iter() + .map(|(y, bend, width)| { + let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect(); + let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect(); + band(&upper, &lower) + }) + .collect(); + add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons); + // Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones + // reach across the side edges and cross the next tiles' lenses. + let lenses = (0..5u32) + .map(|k| { + let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k)); + PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)]) + }) + .collect(); + add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses); +} + #[cfg(test)] mod tests { use super::*; @@ -326,4 +483,101 @@ mod tests { } assert!(crossing > 0, "no mark reaches past an edge"); } + + const OPTICAL: [&str; 8] = + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]; + + fn optical_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + optical_effects(&mut d); + d + } + + /// The paths of pattern `p`'s art, each with its fill rule. + fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> { + p.art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule), + _ => panic!("{}: art that is not a path", p.name), + }) + .collect() + } + + #[test] + fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() { + let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed"); + assert_eq!(b.name, "Optical Effects"); + let names: Vec = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, OPTICAL); + // Made twice, the art is the same. + let (a, again) = (optical_doc(), optical_doc()); + assert_eq!(a.patterns, again.patterns); + for p in &a.patterns { + let (w, h) = (p.width(), p.height()); + assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert_eq!(p.tile_type, TileType::Grid, "{}", p.name); + let paths = paths(p); + let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum(); + assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len()); + for (path, _) in &paths { + let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name)); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + } + } + } + + /// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band + /// cut off at an edge, or one that comes back at another height or width, paints one side only. + /// No path is another moved by a whole tile, which the tiling would draw twice in one place. + #[test] + fn optical_tiles_join_without_a_seam() { + const SAMPLES: u32 = 400; + const STEP: f64 = 1e-4; + for p in &optical_doc().patterns { + let (w, h) = (p.width(), p.height()); + let shapes: Vec<(BezPathFill, Rect)> = + paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect(); + let paints = |q: Point| { + let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP); + p.offsets_covering(near).into_iter().any(|o| { + let at = q - o; + shapes.iter().any(|((bp, rule), b)| { + let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1; + let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 }; + match rule { + vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0, + vectorcraft_geom::FillRule::NonZero => turns != 0, + } + }) + }) + }; + let (mut differ, mut painted) = (0, 0); + for i in 0..SAMPLES { + let t = (f64::from(i) + 0.37) / f64::from(SAMPLES); + let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h)); + let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP)); + for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] { + let (pa, pb) = (paints(a), paints(b)); + differ += usize::from(pa != pb); + painted += usize::from(pa); + } + } + assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES); + assert!(painted > 0, "{}: nothing crosses an edge", p.name); + let bounds: Vec = shapes.iter().map(|(_, b)| *b).collect(); + let same = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + for b in &bounds { + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + } + + type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule); } diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 57d6c23d06..a02fb079c9 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -806,7 +806,7 @@ fn a_grit_texture_adds_once_undoes_and_saves() { let list = run(&mut s, "swatch.library.list", json!({})); let ids: Vec<&str> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); @@ -831,6 +831,49 @@ fn a_grit_texture_adds_once_undoes_and_saves() { assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); } +/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of +/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step +/// that also takes both away, and both stay after a save and reopen. +#[test] +fn an_optical_effect_adds_once_fills_undoes_and_saves() { + let mut s = session(); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!( + names, + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"] + ); + assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch"); + let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks"))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone(); + assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard"); + assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks")); + let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks"); + assert!(filled(doc(&s))); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s))); + run(&mut s, "edit.redo", json!({})); + assert!(filled(doc(&s))); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Warped Checks"), Some(&def)); + assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint)); + assert!(filled(&back)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97e5b335d3..240444ac20 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5851,6 +5851,7 @@ theme Appearance Външен вид Ruby… Руби… Ruby Руби Geometric Guides Геометрични водачи + Optical Effects Оптични ефекти Geometric Patterns Геометрични мостри Grit Textures Зърнести текстури Ease In Плавно начало diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75353321ff..22d1455f73 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5868,6 +5868,7 @@ mark-style Japanese Japanski Ruby… Ruby… Ruby Ruby Geometric Guides Geometrijske vodilice + Optical Effects Optički efekti Geometric Patterns Geometrijski uzorci Grit Textures Zrnaste teksture Ease In Ublaži ulaz diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 75e0b9ea20..2d6e8799d0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2914,6 +2914,7 @@ digits Malayalam Malaiàlam Opens network connections Obre connexions de xarxa Open… Obre… Optical Òptic + Optical Effects Efectes òptics Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic Dynamic Text Presets Valors predefinits de text dinàmic diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d37ee136e..3c1170bf9d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6033,6 +6033,7 @@ mark-style Japanese Japonské Ruby… Ruby… Ruby Ruby Geometric Guides Geometrická vodítka + Optical Effects Optické efekty Geometric Patterns Geometrické vzorky Grit Textures Zrnité textury Ease In Pozvolný začátek diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8167f80138..2a1b9d8007 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5866,6 +5866,7 @@ swatch Bright Violet Kraftig violet Ruby… Ruby… Ruby Ruby Geometric Guides Geometriske hjælpelinjer + Optical Effects Optiske effekter Geometric Patterns Geometriske mønstre Grit Textures Kornede teksturer Ease In Langsom start diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bd4bb38b43..0805be2caf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5622,6 +5622,7 @@ effect Soft Weich Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische Hilfslinien + Optical Effects Optische Effekte Geometric Patterns Geometrische Muster Grit Textures Körnige Texturen Ease In Langsam starten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9860f8ad50..278b6cf81a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5984,6 +5984,7 @@ effect Points Σημεία Ruby… Ruby… Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί + Optical Effects Οπτικά εφέ Geometric Patterns Γεωμετρικά μοτίβα Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10c0c0bf87..f0f804e008 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5975,6 +5975,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guías geométricas + Optical Effects Efectos ópticos Geometric Patterns Motivos geométricos Grit Textures Texturas granuladas Ease In Entrada suave diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7845235f65..471b98e76c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6063,6 +6063,7 @@ swatch Bright Violet Kirkas violetti Ruby… Ruby… Ruby Ruby Geometric Guides Geometriset apuviivat + Optical Effects Optiset tehosteet Geometric Patterns Geometriset kuviot Grit Textures Rakeiset tekstuurit Ease In Hidas alku diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2895f9c2..fe3b98d0e8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6028,6 +6028,7 @@ theme Appearance Apparence Ruby… Ruby… Ruby Ruby Geometric Guides Repères géométriques + Optical Effects Effets optiques Geometric Patterns Motifs géométriques Grit Textures Textures granuleuses Ease In Accélération progressive diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aca21d7eec..74fb288598 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3170,6 +3170,7 @@ antiAlias Sharp शार्प Opens network connections नेटवर्क कनेक्शन ओपन करें OpenType Features ओपन टाइप सुविधाएँ Optical ऑप्टिकल + Optical Effects ऑप्टिकल इफ़ेक्ट्स Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट Dynamic Text Presets डायनैमिक टेक्स्ट प्रीसेट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 45d19b0ca9..55b97d4d21 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6016,6 +6016,7 @@ imageTrace Outline Körvonal Ruby… Ruby… Ruby Ruby Geometric Guides Geometriai segédvonalak + Optical Effects Optikai effektusok Geometric Patterns Geometriai mintázatok Grit Textures Szemcsés textúrák Ease In Lassú kezdés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e2b383e2d..a55f7699bf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5866,6 +5866,7 @@ swatch Bright Violet Violet cerah Ruby… Ruby… Ruby Ruby Geometric Guides Panduan Geometris + Optical Effects Efek Optis Geometric Patterns Pola Geometris Grit Textures Tekstur Butiran Ease In Awal Lambat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 26e1f29651..a9ba306b9f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5656,6 +5656,7 @@ lighting Light Luce Ruby… Ruby… Ruby Ruby Geometric Guides Guide geometriche + Optical Effects Effetti ottici Geometric Patterns Motivi geometrici Grit Textures Texture granulose Ease In Inizio graduale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 30873028e8..fab0450610 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5706,6 +5706,7 @@ lighting Light ライト Ruby… ルビ… Ruby ルビ Geometric Guides 幾何ガイド + Optical Effects オプティカル効果 Geometric Patterns 幾何パターン Grit Textures 粒状テクスチャ Ease In イーズイン diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8e9d9d049d..c8274c9b31 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5973,6 +5973,7 @@ imageTrace Outline კონტური Ruby… რუბი… Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები + Optical Effects ოპტიკური ეფექტები Geometric Patterns გეომეტრიული პატერნები Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f00e6cdeba..93b4ce1bb9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5617,6 +5617,7 @@ lighting Light 광원 Ruby… 루비… Ruby 루비 Geometric Guides 기하학적 안내선 + Optical Effects 광학 효과 Geometric Patterns 기하학적 패턴 Grit Textures 입자 텍스처 Ease In 서서히 시작 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bc00b76698..282b409a58 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5917,6 +5917,7 @@ swatch Bright Violet Felviolet Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische hulplijnen + Optical Effects Optische effecten Geometric Patterns Geometrische patronen Grit Textures Korrelige texturen Ease In Langzaam beginnen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23940fb80f..1899b72951 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5887,6 +5887,7 @@ mark-style Japanese Japońskie Ruby… Ruby… Ruby Ruby Geometric Guides Geometryczne linie pomocnicze + Optical Effects Efekty optyczne Geometric Patterns Wzorki geometryczne Grit Textures Tekstury ziarniste Ease In Łagodny początek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c4b4bcc0..13eee61ed3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5948,6 +5948,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guias geométricas + Optical Effects Efeitos ópticos Geometric Patterns Padrões geométricos Grit Textures Texturas granuladas Ease In Entrada suave diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 15d3c3a380..f1321b48d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5642,6 +5642,7 @@ lighting Light Свет Ruby… Руби… Ruby Руби Geometric Guides Геометрические направляющие + Optical Effects Оптические эффекты Geometric Patterns Геометрические узоры Grit Textures Зернистые текстуры Ease In Плавное начало diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ffc963fad..999be89513 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6055,6 +6055,7 @@ font-script Japanese Japanska Ruby… Ruby… Ruby Ruby Geometric Guides Geometriska stödlinjer + Optical Effects Optiska effekter Geometric Patterns Geometriska mönster Grit Textures Korniga texturer Ease In Långsam start diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86356d74ba..753f6024f6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6028,6 +6028,7 @@ axis Y Axis Y ekseni Snap to Tangent Teğete Yasla Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar + Optical Effects Optik Efektler Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebba0f5c19..ad649f4ab0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5636,6 +5636,7 @@ lighting Light Світло Ruby… Рубі… Ruby Рубі Geometric Guides Геометричні напрямні + Optical Effects Оптичні ефекти Geometric Patterns Геометричні візерунки Grit Textures Зернисті текстури Ease In Плавний початок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0ec0fd4e40..93e9d93881 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5637,6 +5637,7 @@ theme Appearance Giao diện Ruby… Ruby… Ruby Ruby Geometric Guides Đường dẫn hình học + Optical Effects Hiệu ứng quang học Geometric Patterns Họa tiết hình học Grit Textures Kết cấu hạt Ease In Chậm lúc đầu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9fc1cdc4e8..45cd676f69 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5870,6 +5870,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 几何参考线 + Optical Effects 光学效果 Geometric Patterns 几何图案 Grit Textures 颗粒纹理 Ease In 缓入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 685f7887fb..e6393341b3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5674,6 +5674,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 幾何參考線 + Optical Effects 光學效果 Geometric Patterns 幾何圖樣 Grit Textures 顆粒紋理 Ease In 緩入 diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index f28e7e5540..10acd4603e 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,9 +155,10 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "optical-effects", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); assert_eq!(neighbour::(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); + assert_eq!(neighbour::(&app, "grit-textures", 1).as_deref(), Some("optical-effects")); assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..dd910b162a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3373,9 +3373,10 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures -(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, -hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`), Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles) and Optical Effects +(`optical-effects`: VectorCraft's own black wave, ring, warped-check, pinched-stripe, chevron and lens tiles), +category `patterns`, hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..e6bcb23301 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1294,7 +1294,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and three pattern libraries (Geometric Patterns; Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; and Optical Effects with eight of VectorCraft's own seamless wave, ring, warped-check, stripe, chevron and lens tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From 3c1e3cc1120f48dc0dcb8ea158f9067caf110ede Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:00:55 -0800 Subject: [PATCH 09/31] 3D: say that a clipped composite keeps the whole solid in member order With colored lights a surface can be one clipped composite of its facets. The comment above the plain check now states that every surface of such a solid, masked ones included, draws whole in member order. --- crates/effects/src/solids.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4744202dd0..65bff78e7e 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -842,8 +842,9 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option Date: Thu, 12 Nov 2026 01:03:41 -0800 Subject: [PATCH 10/31] 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, Option) { + 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 { "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 { "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 { /// 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![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 { // 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::>() + // 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::>(); 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::>(), + "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::>(), }); 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 { "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, pub(crate) links: Vec, /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). pub(crate) sizes: Arc>>, @@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result { 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 { 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, 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 { "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 { 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, 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, 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/` 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 Date: Thu, 12 Nov 2026 00:45:41 -0800 Subject: [PATCH 11/31] 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 Date: Wed, 11 Nov 2026 21:05:53 -0800 Subject: [PATCH 12/31] 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 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 { 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, Option, 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, kept: &HashSet, out: &mut Vec) -> (Option, 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, + kept: &HashSet, + images: Option<&WarpedImages>, + out: &mut Vec, +) -> (Option, 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 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, +} + +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::().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 { + 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 { + 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, 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 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 { + 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) { + 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::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 { + 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| { + 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 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(" 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 { + 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("= 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]) -> 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!("")); 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 ``; 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 4e03d655c01a7d18065933387d28301da4f69e9f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:20:37 -0800 Subject: [PATCH 13/31] Select: test Not Aligned to Pixel Grid with images, symbols, type and live objects Adds tests for Select > Object > Not Aligned to Pixel Grid with embedded images, symbol instances, point, area and path type, gradient meshes, placed documents, guides and live shapes, with paths on template layers, hidden and locked sublayers and locked artboards, and with the paths in clip groups, blends, envelopes, repeats and Intertwine groups. A compound path is selected by its own property, whatever the property of its paths. The command's params text and docs/mcp.md now say that it looks into clip groups, blends, envelopes, repeats and Intertwine groups. --- crates/engine/src/cmd/select.rs | 2 +- crates/engine/src/tests/pixelalign.rs | 199 ++++++++++++++++++++++++++ docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 4 files changed, 207 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index aeeac58855..a2c7b3a8d4 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -184,7 +184,7 @@ pub fn specs() -> Vec { "Not Aligned to Pixel Grid", ["Select", "Object"], None, - "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}", + "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}", has_doc, not_pixel_aligned ), diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index adb8bae15d..06057d1fc0 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -959,3 +959,202 @@ fn not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected() { assert_eq!(s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap()["count"], 1); assert_eq!(selected_ids(&s), vec![inside]); } + +/// Run Not Aligned to Pixel Grid with `before` selected, check that it makes no undo step and that +/// its count is the number of objects it selected → the selected ids, sorted. +fn not_aligned(s: &mut Session, before: &[u64]) -> Vec { + s.execute("select.set", &json!({ "ids": before })).unwrap(); + let steps = undo_len(s); + let r = s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap(); + let ids = selected_ids(s); + assert_eq!(r["count"], json!(ids.len()), "{r}"); + assert_eq!(undo_len(s), steps); + ids +} + +fn sorted(mut ids: Vec) -> Vec { + ids.sort_unstable(); + ids +} + +/// What Select All selects, sorted. +fn all_ids(s: &mut Session) -> Vec { + s.execute("select.all", &json!({})).unwrap(); + selected_ids(s) +} + +fn kind(s: &Session, id: u64) -> NodeKind { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind.clone() +} + +/// Images, symbol instances, point type, area type and type on a path cannot take the property, so +/// Not Aligned to Pixel Grid leaves them out though Select All selects them; the path a symbol was +/// made from is the symbol's art, not an object of the document. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = s.doc().unwrap().selection.objects[0].0; + let art = path(&mut s, "M60.5 60 L80 60 L80 80 L60.5 80 Z", 1.0); + let made = s.execute("symbol.new", &json!({"name": "Mark", "ids": [art]})).unwrap()["id"].as_u64().unwrap(); + let placed = s.execute("symbol.place", &json!({"name": "Mark", "x": 200.3, "y": 200.3})).unwrap()["id"].as_u64().unwrap(); + let point = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "point"})).unwrap()["id"].as_u64().unwrap(); + let frame = json!({"width": 80.5, "height": 40.5}); + let area = s.execute("text.create", &json!({"x": 100, "y": 150, "text": "area", "area": frame})).unwrap()["id"].as_u64().unwrap(); + let on_path = path(&mut s, "M10.5 300 C100 250 200 350 300.5 300", 1.0); + let on_path = s.execute("text.createInPath", &json!({"path": on_path, "mode": "onPath", "text": "on a path"})).unwrap()["id"].as_u64().unwrap(); + let in_path = path(&mut s, "M250.5 20.5 L350 20.5 L350 90 L250.5 90 Z", 1.0); + let in_path = s.execute("text.createInPath", &json!({"path": in_path, "mode": "area", "text": "in a path"})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, image), NodeKind::Image(_))); + for id in [made, placed] { + assert!(matches!(kind(&s, id), NodeKind::SymbolInstance { .. }), "{id}"); + } + for id in [point, area, on_path, in_path] { + assert!(matches!(kind(&s, id), NodeKind::Text(_)), "{id}"); + } + assert!(s.doc().unwrap().doc.node(NodeId(art)).is_none()); + assert_eq!(all_ids(&mut s), sorted(vec![p, image, made, placed, point, area, on_path, in_path])); + assert_eq!(not_aligned(&mut s, &[image, made, point]), vec![p]); + // With the path aligned, nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": [p]})).unwrap(); + assert_eq!(not_aligned(&mut s, &[placed]), Vec::::new()); +} + +/// Paths on a template layer, in a hidden or locked sublayer or on a locked artboard are left out, +/// as Select All leaves them out; paths in an unlocked visible sublayer are selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards() { + let mut s = new_doc(); + s.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 100})).unwrap(); + let main = s.doc().unwrap().doc.layers[0].id.0; + let free = path(&mut s, "M10 10 L20 10 L20 20 L10 20 Z", 1.0); + let on_locked_board = path(&mut s, "M510 10 L520 10 L520 20 L510 20 Z", 1.0); + let template = s.execute("layer.new", &json!({"name": "Trace"})).unwrap()["id"].as_u64().unwrap(); + let traced = path(&mut s, "M30 10 L40 10 L40 20 L30 20 Z", 1.0); + s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap(); + let sub = |s: &mut Session, name: &str, d: &str| { + let l = s.execute("layer.newSublayer", &json!({"parent": main, "name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": l})).unwrap(); + (l, path(s, d, 1.0)) + }; + let (_, in_sublayer) = sub(&mut s, "Open", "M50 10 L60 10 L60 20 L50 20 Z"); + let (hidden, in_hidden) = sub(&mut s, "Hidden", "M70 10 L80 10 L80 20 L70 20 Z"); + let (locked, in_locked) = sub(&mut s, "Locked", "M90 10 L100 10 L100 20 L90 20 Z"); + s.execute("layer.setProps", &json!({"id": hidden, "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"id": locked, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let all = all_ids(&mut s); + for id in [traced, in_hidden, in_locked, on_locked_board] { + assert!(!all.contains(&id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer])); + // Unlocked, the artboard's path is selected again. + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer, on_locked_board])); +} + +/// A clipping path and the paths it clips are selected, not their clip group; a path with a drop +/// shadow or a Gaussian Blur is selected as the path it is, and its effects are not objects. +#[test] +fn not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects() { + let mut s = new_doc(); + let clipped = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + let clip = path(&mut s, "M20.5 20 L50 20 L50 50 L20.5 50 Z", 1.0); + s.execute("select.set", &json!({"ids": [clipped, clip]})).unwrap(); + let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let shadowed = path(&mut s, "M100.5 10 L150 10 L150 60 L100.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [shadowed]})).unwrap(); + let blurred = path(&mut s, "M200.5 10 L250 10 L250 60 L200.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [blurred]})).unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Group { .. })); + for id in [shadowed, blurred] { + assert!(!s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects.is_empty(), "{id}"); + } + let want = sorted(vec![clipped, clip, shadowed, blurred]); + assert_eq!(not_aligned(&mut s, &[group]), want); + // Object › Align to Pixel Grid takes each object selected; then nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": want})).unwrap(); + assert_eq!(not_aligned(&mut s, &[group]), Vec::::new()); +} + +/// The filter looks into blends, envelopes, repeats and Intertwine groups and selects the paths +/// they are made of, not the objects that hold them; Align to Pixel Grid takes that selection, and +/// then nothing is left to select. +#[test] +fn not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines() { + let mut s = new_doc(); + let b1 = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let b2 = path(&mut s, "M80.5 10 L100 10 L100 30 L80.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [b1, b2]})).unwrap(); + let blend = s.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let warped = path(&mut s, "M10.5 100 L100 100 L100 150 L10.5 150 Z", 1.0); + let envelope = s.execute("object.envelope.makeWithWarp", &json!({"ids": [warped]})).unwrap()["id"].as_u64().unwrap(); + let tile = path(&mut s, "M200.5 10 L220 10 L220 30 L200.5 30 Z", 1.0); + let repeat = s.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap(); + let i1 = path(&mut s, "M200.5 200 L260 200 L260 230 L200.5 230 Z", 1.0); + let i2 = path(&mut s, "M220.5 180 L240 180 L240 250 L220.5 250 Z", 1.0); + let intertwine = s.execute("object.intertwine.make", &json!({"ids": [i1, i2]})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, blend), NodeKind::Blend { .. })); + assert!(matches!(kind(&s, envelope), NodeKind::Envelope { .. })); + assert!(matches!(kind(&s, repeat), NodeKind::Repeat(_))); + assert!(matches!(kind(&s, intertwine), NodeKind::Intertwine { .. })); + let want = sorted(vec![b1, b2, warped, tile, i1, i2]); + assert_eq!(not_aligned(&mut s, &[blend, envelope]), want); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + for id in &want { + assert!(aligned(&s, *id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[repeat, intertwine]), Vec::::new()); +} + +/// Gradient meshes, placed documents and ruler guides are left out; a live shape is a path and is +/// selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes() { + let mut s = new_doc(); + let filled = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + let mesh = s.execute("object.mesh.create", &json!({"ids": [filled], "rows": 2, "cols": 2})).unwrap()["ids"][0].as_u64().unwrap(); + let dir = crate::tests_links::Folder::new("pixelalign-placed"); + let file = dir.file("placed.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(50.0, 50.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.5, 0.5, 40.0, 40.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(20, 20, 230)); + let look = vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, rect, look)).unwrap(); + crate::tests_links::write(&file, &vectorcraft_format::save_file(&d)); + let placed = s.execute("file.place", &json!({"path": file, "at": [200, 200]})).unwrap()["ids"][0].as_u64().unwrap(); + let guide = path(&mut s, "M0 300.5 L400 300.5", 1.0); + s.execute("view.guides.make", &json!({})).unwrap(); + let live = s.execute("shape.rectangle", &json!({"x": 100.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, mesh), NodeKind::Mesh(_))); + assert!(matches!(kind(&s, placed), NodeKind::PlacedDocument(_))); + assert!(matches!(kind(&s, guide), NodeKind::Path { guide: true, .. })); + assert!(s.doc().unwrap().doc.node(NodeId(live)).unwrap().live_shape().is_some()); + let all = all_ids(&mut s); + assert!(all.contains(&mesh) && all.contains(&placed), "{all:?}"); + assert_eq!(not_aligned(&mut s, &[mesh, placed]), vec![live]); +} + +/// A compound path goes by its own property, whatever the property of the paths in it (a file can +/// hold paths with the property on inside a compound path): off, it is selected as one object and +/// the paths in it are not; on, it is not selected, though a path in it has the property off. +#[test] +fn not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property() { + let mut s = new_doc(); + let on = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let off = path(&mut s, "M50.5 10 L70 10 L70 30 L50.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [on, off]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(on)).unwrap().extras_mut().pixel_aligned = true; + assert!(aligned(&s, on) && !aligned(&s, off) && !aligned(&s, compound)); + let other = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + assert_eq!(not_aligned(&mut s, &[other]), sorted(vec![compound, other])); + s.execute("object.setPixelAligned", &json!({"ids": [compound]})).unwrap(); + assert!(!aligned(&s, off)); + assert_eq!(not_aligned(&mut s, &[compound]), vec![other]); +} diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..5693acd159 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2330,9 +2330,12 @@ everything in it, and `select.all`, `select.allOnArtboard` and `select.inverse` `brush.list`), whatever the brush is named; a brush of another type with "Bristle" in its name does not count. `select.object.notAlignedToPixelGrid` (Select › Object › Not Aligned to Pixel Grid) selects the paths and compound paths whose pixel-aligned property (`object.setPixelAligned`) is off and returns `{count}`. It goes by the property, -not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type, images, -groups and other objects are not, nor are the paths inside a compound path, which takes the property as one object. -It looks into groups, replaces the selection, and with no such object clears it; it records no undo step. +not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type (point, +area and on a path), images, symbol instances, gradient meshes, placed documents, groups and other objects are not, +nor are the paths inside a compound path, which takes the property as one object. A path with effects is selected +as the path it is. It looks into groups, clip groups, blends, envelopes, repeats and Intertwine groups and selects the +paths they hold, not the objects that hold them. It replaces the selection, and with no such object clears it; it +records no undo step. `select.nextAbove` and `select.nextBelow` select the nearest such object above or below the first selected object among the objects beside it in its group or layer, passing over hidden and locked objects and sublayers. When there is no such object, the selection stays as it is. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4cc4b01cda 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -946,7 +946,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. - Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). + Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). It also leaves out symbol instances, area type, type on a path, gradient meshes and placed documents, and the paths on template layers, in hidden or locked sublayers and on locked artboards; it selects the paths in clip groups, blends, envelopes, repeats and Intertwine groups and paths with effects (`not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type`, `not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards`, `not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects`, `not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines`, `not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes`, `not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property`). Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. Align to Glyph Bounds (#4231): the Align panel menu's Align to Glyph Bounds submenu has Point Text and Area Text, each checked while on (`alignGlyphBoundsPoint`, `alignGlyphBoundsArea`, off by default). With one on, Align, Distribute Objects and Distribute Spacing measure that kind of type by the ink of its glyphs instead of its em box (point type) or frame (area type), so lowercase type aligned to a rectangle's top lines up the tops of its letters; the other kind, type on a path and type in a selected group are measured as before, and Use Preview Bounds adds the characters' strokes ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html); `tests::glyphbounds`, `panels::align::tests::align_to_glyph_bounds_toggles_point_and_area_text`). The Character and Properties panels' controls for the same setting are not done. Anchor Align moves the selected points to their bounds or the artboard (#1180). Repeating it, or moving by at most 1e-9 pt on both axes as whole-object Align does, leaves the document, Undo, Redo and saved state unchanged; actual moves still take one undo step. `crates/engine/src/tests.rs` covers repeated alignment, rounding-only alignment, unchanged handles, saved state, command recording and Undo/Redo. From 98d6cedf2530ed31dca722052ac488fcdec9a0fd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:27:01 -0800 Subject: [PATCH 14/31] 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 { + 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 { + 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 2ee2ac6245a55a0ffe830681594fb6484e2dc89e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:41:55 -0800 Subject: [PATCH 15/31] Objects on Path: the Select/Move widget chooses an object and drags it to another place along the path With one Objects on Path group selected, the Selection and Objects on Path tools show a ring on each object's attachment point. A click chooses that object while the group stays selected; dragging a ring along the path moves its object to the nearest object's place, and the objects between move one place toward where it was, in one undo step. Hidden and locked objects have no ring and keep their places. On a closed path the drag goes on round across the first anchor, and Escape cancels it. - object.objectsOnPath.selectMember {member} chooses an object. - object.objectsOnPath.moveMember {member, target, group?} moves an object to another object's place; only the order along the path changes. Docs: docs/mcp.md, docs/target-app-parity.md. Not yet: Detach. --- crates/doc/src/objects_on_path.rs | 48 +++ crates/doc/src/selection.rs | 31 ++ crates/doc/src/selection/tests.rs | 43 ++ crates/engine/src/cmd/objects_on_path.rs | 91 +++- crates/engine/src/tests/objects_on_path.rs | 133 ++++++ crates/tools/src/objects_on_path.rs | 458 ++++++++++++++++++++- crates/tools/src/select.rs | 31 +- crates/ui-egui/src/canvas/overlays.rs | 24 +- crates/ui-egui/src/canvas/tests.rs | 32 ++ docs/mcp.md | 24 ++ docs/target-app-parity.md | 3 +- 11 files changed, 899 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index cace68ce66..26a11cf733 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -180,6 +180,24 @@ impl ObjectsOnPath { }); Some((spacing, start)) } + /// Move the object `member` to the place on the path of the object `target`: the objects + /// between them each move one place toward where `member` was, except the objects for which + /// `fixed` is true, which keep their places and are passed over. Only the order changes, not the + /// Space or the start. Returns `member`'s new index, or `None` when either one is not an object + /// of the group. + pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option { + let moves = |id: NodeId| id == member || id == target || !fixed(id); + let slots: Vec = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect(); + let from = slots.iter().position(|&i| self.members[i].id == member)?; + let to = slots.iter().position(|&i| self.members[i].id == target)?; + let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect(); + let m = moving.remove(from); + moving.insert(to, m); + for (&i, m) in slots.iter().zip(moving) { + self.members[i] = m; + } + Some(slots[to]) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -487,6 +505,36 @@ mod tests { assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); } + #[test] + fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() { + let (a, b, c) = + (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0))); + let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) }; + let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::>(); + let free = |_: NodeId| false; + let mut fwd = rec.clone(); + assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2)); + assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place"); + assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate)); + let mut back = rec.clone(); + assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0)); + assert_eq!(order(&back), [4, 2, 3], "the last to the first place"); + let mut same = rec.clone(); + assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1)); + assert_eq!(same, rec, "onto its own place"); + let mut other = rec.clone(); + assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects"); + assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None); + assert_eq!(other, rec); + // A fixed object between them keeps its place; the others pass over it. + let mut over = rec.clone(); + assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2)); + assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one"); + let mut back_over = rec.clone(); + assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0)); + assert_eq!(order(&back_over), [4, 3, 2]); + } + #[test] fn a_group_saved_without_space_reads_as_auto() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index c514aab996..6f0db556e0 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -59,6 +59,11 @@ pub struct Selection { /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. #[serde(default, skip_serializing_if = "Option::is_none")] pub symbol_part: Option, + /// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the + /// one selected object, and the object is one of its objects on the path + /// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_path_member: Option, } /// See [`Selection::symbol_part`]. @@ -94,6 +99,13 @@ impl Selection { self.guides.clear(); self.key_guide = None; self.symbol_part = None; + self.on_path_member = None; + } + /// Select only the Objects on Path group `group`, with its object `member` (see + /// [`Selection::on_path_member`]). + pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) { + self.set([group]); + self.on_path_member = Some(member); } /// Select only instance `instance`, with the object at `path` of its symbol's art (see /// [`Selection::symbol_part`]). @@ -115,6 +127,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -123,6 +136,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -152,6 +166,7 @@ impl Selection { /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; + self.on_path_member = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -258,6 +273,22 @@ impl Selection { if !part_kept { self.symbol_part = None; } + // A chosen object on a path needs its Objects on Path group selected alone, with no anchors + // or guides, and still one of the group's objects, neither hidden nor locked. + let member_kept = self.on_path_member.is_some_and(|m| { + let [g] = self.objects.as_slice() else { return false }; + self.anchors.is_empty() + && self.guides.is_empty() + && doc.is_editable(m) + && found + .get(g) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n))) + .is_some_and(|rec| rec.members.iter().any(|x| x.id == m)) + }); + if !member_kept { + self.on_path_member = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d0d81faee6..d9df845129 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() { assert_eq!(s.target, None); } +#[test] +fn a_chosen_object_on_a_path_needs_its_group_selected_alone() { + use vectorcraft_geom::{Point, Rect, shapes}; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default()); + let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default()); + let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default()); + let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0); + let (line_id, a_id) = (line.id, a.id); + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + d.insert(Some(l), 0, group).unwrap(); + d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap(); + let mut s = Selection::default(); + s.set_on_path_member(g, a_id); + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id))); + s.add(other); + assert_eq!(s.on_path_member, None, "another object joins the selection"); + s.set_on_path_member(g, a_id); + s.set([g]); + assert_eq!(s.on_path_member, None, "a new selection"); + // The path is not one of the objects, nor is an object outside the group. + for m in [line_id, other] { + s.set_on_path_member(g, m); + s.prune(&d); + assert_eq!(s.on_path_member, None, "{m:?}"); + } + // A locked object is not chosen. + s.set_on_path_member(g, a_id); + d.node_mut(a_id).unwrap().locked = true; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked"); + d.node_mut(a_id).unwrap().locked = false; + // Once the group is a plain group, no object of it is chosen. + s.set_on_path_member(g, a_id); + d.node_mut(g).unwrap().extras_mut().objects_on_path = None; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None)); +} + #[test] fn a_key_object_needs_two_or_more_selected_objects() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 1a36a4e29f..296cef4dc2 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -9,6 +9,10 @@ //! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the //! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the //! first one attaches), and `object.objectsOnPath.expand` makes it a plain group. +//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move +//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the +//! path, the objects between it and that place each moving one place toward where it was; hidden +//! and locked objects keep their places. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -19,7 +23,7 @@ //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, //! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::edit::{roots_of, selected_roots}; -use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection}; +use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection}; /// The id of the Objects on Path tool. pub const TOOL: &str = "objectsOnPath"; @@ -53,6 +57,24 @@ pub fn specs() -> Vec { has_objects_on_path, options ), + cmd!( + "object.objectsOnPath.selectMember", + "Select Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}", + has_doc, + select_member + ), + cmd!( + "object.objectsOnPath.moveMember", + "Move Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}", + has_doc, + move_member + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::>() })) } +// ---------- Select/Move ---------- + +/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the +/// group's record (not its path), in a group that is not hidden or locked. +fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> { + let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; + let id = checked_id(s, v, cmd)?; + let d = &s.doc()?.doc; + let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group")); + let g = + d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?; + let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id)); + if !listed { + return Err(not_on_path()); + } + if !d.is_editable(g) { + return Err(bad(cmd, "the Objects on Path group is hidden or locked")); + } + if !d.is_editable(id) { + return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked"))); + } + Ok((g, id)) +} + +fn select_member(s: &mut Session, p: &Value) -> Result { + let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?; + s.select(|_, sel| sel.set_on_path_member(g, member))?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + +fn move_member(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.objectsOnPath.moveMember"; + let (g, member) = member_param(s, p, "member", C)?; + let (target_group, target) = member_param(s, p, "target", C)?; + if target_group != g { + return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`")); + } + if let Some(v) = p.get("group").filter(|v| !v.is_null()) + && checked_id(s, v, C)? != g + { + return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`")); + } + let at = |s: &Session| -> Result { + let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member)) + }; + // Onto its own place: nothing changes, and no undo step is recorded. + if target == member { + return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? })); + } + // Hidden and locked objects between them keep their places. + let index = s.edit("Move Object on Path", |d, _| { + let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + let fixed: HashSet = { + let editable = d.editable(); + rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect() + }; + let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?; + let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?; + n.extras_mut().objects_on_path = Some(Box::new(rec)); + Ok(index) + })?; + Ok(json!({ "group": g.0, "member": member.0, "index": index })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 8c0f359df4..4ae8321167 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path() assert_eq!(space(&s, g), (Some(100.0), 0.0)); } +/// The ids of group `g`'s objects in their order along the path, and of its children in the +/// Layers panel's order. +fn orders(s: &Session, g: NodeId) -> (Vec, Vec) { + let n = s.doc().unwrap().doc.node(g).unwrap(); + let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect(); + (path, n.children().unwrap().iter().map(|c| c.id).collect()) +} + +/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300), +/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0. +fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) { + let a = rect(s, 0.0, 0.0, 20.0, 20.0); + let b = rect(s, 100.0, 0.0, 40.0, 20.0); + let c = rect(s, 200.0, 0.0, 10.0, 20.0); + let path = line(s, (50.0, 300.0), (350.0, 300.0)); + let g = attach(s, path, &[a, b, c], json!({})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap(); + (g, [a, b, c], path) +} + +#[test] +fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let (_, layers) = orders(&s, g); + let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2})); + assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(center(&s, b), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(150.0, 300.0)); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!( + (after.path, after.pivot, after.rotate, after.spacing, after.start), + (path, before.pivot, before.rotate, before.spacing, before.start) + ); + let w = bounds(&s, a); + assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}"); + // The last one back to the first place, with its group named. + let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap(); + assert_eq!(v["index"], 0); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, c), Point::new(250.0, 300.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + // Onto its own place: nothing changes and no step is recorded. + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 1); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // The order saves and reopens. + let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap(); + assert_eq!(orders(&o, g), (vec![b, c, a], layers)); + assert_eq!(center(&o, a), Point::new(250.0, 300.0)); +} + +#[test] +fn move_member_refuses_what_is_not_an_object_on_the_same_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0); + let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0)); + let h = attach(&mut s, other_line, &[x], json!({})); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target"); + assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object"); + assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named"); + assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`")); + assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`")); + assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id")); + // A locked object neither moves nor gives its place, and is not chosen. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked")); + assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked")); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err()); + s.execute("object.unlockAll", &json!({})).unwrap(); + // A locked object between the two keeps its place, and the others pass over it. + select(&mut s, &[b]); + s.execute("object.lock", &json!({})).unwrap(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 2); + assert_eq!(orders(&s, g).0, [c, b, a]); + assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).unwrap().locked); + let e = err(&mut s, json!({"member": a.0, "target": b.0})); + assert!(e.contains("hidden or locked"), "a locked group: {e}"); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen"); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order"); +} + +#[test] +fn select_member_selects_the_group_with_the_object_chosen() { + let mut s = session(); + let (g, [a, b, _], path) = three_on_a_line(&mut s); + let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": b.0})); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b))); + // Moving the chosen object keeps it chosen. + s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b)); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err()); + select(&mut s, &[g]); + assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it"); + // Both take their objects without a selection. + select(&mut s, &[]); + s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap(); + assert_eq!(orders(&s, g).0[..2], [a, b]); + s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a)); +} + #[test] fn the_space_saves_and_reopens_and_goes_with_a_copy() { let mut s = session(); diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index c2f90d0bef..a60c9fb15a 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -29,12 +29,24 @@ //! distance along the path shows next to the pointer in the document's units, the drag is //! previewed and kept as one undo step on release, and Escape cancels it. //! +//! The Select/Move widgets: with one Objects on Path group selected, the same tools show a small +//! ring on each object's attachment point, highlighted under the pointer. A click on one chooses +//! that object (`object.objectsOnPath.selectMember`), and its ring is filled; the group stays +//! selected, and a click on the object's art still selects the group. Dragging a ring along the +//! path moves its object to the place of the object nearest the pointer, and the objects between +//! move one place toward where it was (`object.objectsOnPath.moveMember`): only their order along +//! the path changes, and hidden and locked objects keep their places. On an open path a pointer beyond an end takes the place at that end; on a +//! closed path the drag goes on round across the first anchor. The drag is previewed and kept as +//! one undo step on release, and Escape cancels it. +//! //! Behavior: , //! . use serde_json::{Value, json}; use vectorcraft_doc::blend::Spine; -use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; +use vectorcraft_doc::objects_on_path::{ + CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, +}; use vectorcraft_doc::{Document, Node, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -68,6 +80,11 @@ pub const MOVE_ALL_HALF_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit the Move All widget. const MOVE_ALL_HIT_PX: f64 = 6.0; +/// The radius (screen px) of the Select/Move widget's ring, as the canvas draws it. +pub const MEMBER_RADIUS_PX: f32 = 4.5; +/// How near (screen px) the pointer must be to hit a Select/Move widget. +const MEMBER_HIT_PX: f64 = 6.0; + /// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with /// its record, its children and its path: only when the selection is that one editable group, on /// a path with length, and no anchors are Direct-Selected. @@ -567,6 +584,180 @@ impl MoveAllDrag { } } +/// The Select/Move widget of one object of a selected Objects on Path group: a small ring on the +/// object's attachment point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MemberWidget { + pub group: NodeId, + /// The object. + pub member: NodeId, + /// The object's place along the path (0: the first). + pub index: usize, + /// The object's attachment point, in the document: the widget's center. + pub at: Point, + /// Is the object the chosen one ([`Selection::on_path_member`])? + pub chosen: bool, +} + +impl MemberWidget { + /// The widgets of the selection at `zoom` (screen px per document point), one per object in + /// its order along the path that is neither hidden nor locked: only when the selection is one + /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. + pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec { + let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; + let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + let editable = doc.editable(); + rec.members + .iter() + .zip(at) + .enumerate() + .filter(|(_, (m, (p, _)))| p.x.is_finite() && p.y.is_finite() && editable(m.id)) + .map(|(index, (m, (at, _)))| Self { group, member: m.id, index, at, chosen: selection.on_path_member == Some(m.id) }) + .collect() + } + + /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the + /// chosen one, else the first along the path. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let reach = cx.tol(MEMBER_HIT_PX); + // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. + let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; + if !path.control_bounds()?.inflate(reach, reach).contains(p) { + return None; + } + let mut best: Option<(f64, Self)> = None; + for w in Self::all(cx.doc, cx.selection, cx.zoom) { + let d = w.at.distance(p); + let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); + if d <= reach && nearer { + best = Some((d, w)); + } + } + best.map(|(_, w)| w) + } +} + +/// Is `p` over a Select/Move widget? +pub fn over_member(cx: &ToolContext, p: Point) -> bool { + MemberWidget::at_point(cx, p).is_some() +} + +/// The highlight of the Select/Move widget under `p`, if any: a ring around it. +pub fn member_hover(cx: &ToolContext, p: Point) -> Option { + let w = MemberWidget::at_point(cx, p)?; + let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); + let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); + Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) +} + +/// Dragging a Select/Move widget: the object goes to the place along the path nearest the +/// pointer, and the objects between it and its place move one place toward where it was, except +/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond +/// an end takes the place at that end; on a closed path the pointer goes on round across the first +/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). +#[derive(Clone, Debug)] +pub struct MemberDrag { + widget: MemberWidget, + /// The objects in their order along the path when the press began. + order: Vec, + /// The distance along the path to each place (points), in that order. + places: Vec, + /// Can the drag take each place? Not the place of a hidden or locked object. + open: Vec, + /// The length of the path (points). + len: f64, + /// Is the path closed? + closed: bool, + start: Point, + began: bool, + /// The distance along the path to the point nearest the pointer at the last drag (points). + last: f64, + /// The place the drag puts the object at. + target: usize, +} + +impl MemberDrag { + /// The drag has left the click: the object is moving. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a Select/Move widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = MemberWidget::at_point(cx, ev.pos)?; + let n = cx.doc.node(widget.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + let count = rec.members.len(); + let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); + let places: Vec = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); + let last = *places.get(widget.index)?; + let editable = cx.doc.editable(); + let open = rec.members.iter().map(|m| editable(m.id)).collect(); + let order = rec.members.iter().map(|m| m.id).collect(); + Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + } + + /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed + /// path. + fn apart(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.rem_euclid(self.len).min(self.len - d.rem_euclid(self.len)) } else { d } + } + + /// The place nearest the distance `t` along the path, of the places the drag can take; the + /// current one where another is as near. + fn nearest_place(&self, t: f64) -> usize { + let t = if self.closed { t } else { t.clamp(0.0, self.len) }; + let now = self.places.get(self.target).map_or(f64::INFINITY, |s| self.apart(t, *s)); + let mut best = (self.target, now); + for (i, s) in self.places.iter().enumerate().filter(|(i, _)| self.open.get(*i).copied().unwrap_or(false)) { + let d = self.apart(t, *s); + if d.is_finite() && d < best.1 - 1e-9 { + best = (i, d); + } + } + best.0 + } + + /// The pointer moved to `p`. + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Move Object on Path".into())); + } + let w = &self.widget; + let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + if !t.is_finite() { + return out; + } + self.last = t; + let target = self.nearest_place(t); + if target != self.target { + self.target = target; + let to = self.order.get(target).copied().unwrap_or(w.member); + out.push(Action::Preview("object.objectsOnPath.moveMember".into(), json!({ "group": w.group.0, "member": w.member.0, "target": to.0 }))); + } + out + } + + /// The release: a drag that moved the object keeps it as one undo step, one that ends where it + /// began keeps nothing, and either way the object is chosen. + pub fn finish(self) -> Vec { + let choose = Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": self.widget.member.0 })); + match (self.began, self.target == self.widget.index) { + (false, _) => vec![choose], + (true, true) => vec![Action::Cancel, choose], + (true, false) => vec![Action::Commit, choose], + } + } +} + /// The path of the Objects on Path group `g` of `doc`, to measure along. fn group_spine(doc: &Document, g: NodeId) -> Option { let n = doc.node(g)?; @@ -602,6 +793,10 @@ pub struct ObjectsOnPathTool { spacing: Option, /// The Move All widget drag, while the button is down after a press on the widget. moving: Option, + /// The Select/Move widget drag, while the button is down after a press on a widget. + member: Option, + /// Where the pointer is while no button is down, for the Select/Move widget it is over. + pointer: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -610,7 +805,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, moving: None, member: None, pointer: None, pivot: CENTER, rotate: 0.0 } } } @@ -642,9 +837,18 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.member { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p), + PointerKind::Up => self.member.take().map(MemberDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } + self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) }; + let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); + self.hover = if widget { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -663,6 +867,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.member = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -689,16 +898,22 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.moving { o.extend(d.overlays(cx)); } + if let Some(p) = self.pointer.filter(|_| !self.busy()) { + o.extend(member_hover(cx, p)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.member.is_some() { + return Cursor::Move; + } let idle = self.spacing.is_none() && self.moving.is_none(); if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { Cursor::Rotate } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) { + } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { Cursor::Move } else if target(cx, p).is_some() { Cursor::ObjectsOnPath @@ -708,8 +923,8 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place - /// they had. + /// Rotate All, Space, Move All or Select/Move drag, so the objects go back to the Rotate, Space, + /// place or order they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; @@ -717,7 +932,8 @@ impl Tool for ObjectsOnPathTool { let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); let moved = self.moving.take().is_some_and(|d| d.adjusting()); - if rotated || spaced || moved { + let ordered = self.member.take().is_some_and(|d| d.adjusting()); + if rotated || spaced || moved || ordered { return vec![Action::Cancel]; } } @@ -747,13 +963,14 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() || self.member.is_some() } fn transforming(&self) -> bool { self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) || self.moving.as_ref().is_some_and(MoveAllDrag::adjusting) + || self.member.as_ref().is_some_and(MemberDrag::adjusting) } } @@ -1469,4 +1686,229 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); } } + + /// A document holding an Objects on Path group of `n` 20 × 20 squares attached by their centers + /// to `path`, with the group's Space `spacing`, a selection of the group, and the squares' ids + /// in their order along the path. + fn members_doc(path: vectorcraft_geom::PathData, n: usize, spacing: Option) -> (Document, NodeId, Selection, Vec) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, g) = (d.alloc_id(), d.alloc_id()); + let line = Node::path(line, path, Appearance::default_art()); + let squares: Vec = (0..n) + .map(|i| { + let x = 50.0 * i as f64; + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 50.0, x + 20.0, 70.0)), Appearance::default_art()) + }) + .collect(); + let ids: Vec = squares.iter().map(|s| s.id).collect(); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { + spacing, + ..vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &squares.iter().collect::>(), CENTER, 0.0) + }; + let mut children = vec![Arc::new(line)]; + children.extend(squares.into_iter().map(Arc::new)); + let mut group = Node::new(g, NodeKind::Group { children, clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, g, sel, ids) + } + + /// Three squares on the line from (0, 300) to (400, 300): at 0, 200 and 400. + fn three_doc() -> (Document, NodeId, Selection, Vec) { + members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, None) + } + + /// The `target` a drag's actions preview for `object.objectsOnPath.moveMember`, if any. + fn previewed_target(acts: &[Action]) -> Option { + acts.iter().rev().find_map(|a| match a { + Action::Preview(cmd, p) if cmd == "object.objectsOnPath.moveMember" => p["target"].as_u64(), + _ => None, + }) + } + + fn choose(id: NodeId) -> Action { + Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": id.0 })) + } + + #[test] + fn a_select_move_widget_sits_on_each_attachment_point() { + let (d, g, sel, ids) = three_doc(); + let ws = MemberWidget::all(&d, &sel, 1.0); + let at: Vec<(NodeId, usize, Point, bool)> = ws.iter().map(|w| (w.member, w.index, w.at, w.chosen)).collect(); + assert!(ws.iter().all(|w| w.group == g)); + assert_eq!(at.len(), 3); + for (i, x) in [0.0, 200.0, 400.0].into_iter().enumerate() { + assert!(at[i].0 == ids[i] && at[i].1 == i && near(at[i].2, Point::new(x, 300.0)) && !at[i].3, "{at:?}"); + } + let mut chosen = sel.clone(); + chosen.on_path_member = Some(ids[1]); + assert_eq!(MemberWidget::all(&d, &chosen, 1.0).iter().map(|w| w.chosen).collect::>(), [false, true, false]); + // None for an object in the group, with anchors Direct-Selected, or for a locked group. + let mut one = Selection::default(); + one.set([ids[0]]); + assert!(MemberWidget::all(&d, &one, 1.0).is_empty()); + let mut anchors = sel.clone(); + anchors.anchors.insert(ids[0], [(0, 0)].into()); + assert!(MemberWidget::all(&d, &anchors, 1.0).is_empty()); + let (mut locked, lg, lsel, _) = three_doc(); + locked.node_mut(lg).unwrap().locked = true; + assert!(MemberWidget::all(&locked, &lsel, 1.0).is_empty()); + } + + /// A click on a widget chooses its object without moving it, with the Selection tool and the + /// Objects on Path tool; a click on the object's art away from the widget does not. + #[test] + fn a_click_on_a_select_move_widget_chooses_its_object() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Move, 202.0, 301.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { color, .. } if *color == HIGHLIGHT)), "{}: highlighted", t.id()); + assert_eq!(t.cursor(&c, Point::new(202.0, 301.0), Mods::default()), Cursor::Move, "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Down, 202.0, 301.0)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 203.0, 301.0)).is_empty(), "{}: within the click", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 203.0, 301.0)), vec![choose(ids[1])], "{}", t.id()); + assert!(!t.busy()); + // On the art, 8 px from the attachment point: not the widget. + t.pointer(&c, &ev(PointerKind::Down, 208.0, 300.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 208.0, 300.0)); + assert!(!acts.contains(&choose(ids[1])), "{}: {acts:?}", t.id()); + } + } + + /// A widget at an end of the path is within reach from beyond the path's bounds too, up to + /// 6 px away. + #[test] + fn a_select_move_widget_at_an_end_is_hit_from_beyond_the_path() { + let (d, _, sel, _) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + assert!(over_member(&c, Point::new(-5.0, 300.0)), "before the start"); + assert!(over_member(&c, Point::new(405.0, 300.0)), "after the end"); + assert!(over_member(&c, Point::new(0.0, 305.5)), "beside the line"); + assert!(!over_member(&c, Point::new(0.0, 306.5)), "out of reach"); + assert!(!over_member(&c, Point::new(-6.5, 300.0)), "out of reach before the start"); + } + + /// Dragging the middle object's widget past the last one moves it to the last place; dragging + /// the last one's beyond the start of the line moves it to the first place. Each drag is one + /// undo step, and the release chooses the object. + #[test] + fn dragging_a_select_move_widget_moves_its_object_to_the_nearest_place() { + let (d, g, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 280.0, 310.0)); + assert_eq!(a, vec![Action::Begin("Move Object on Path".into())], "{}: still nearest its own place", t.id()); + assert!(t.transforming() && t.cursor(&c, Point::new(280.0, 310.0), Mods::default()) == Cursor::Move); + let a = t.pointer(&c, &ev(PointerKind::Drag, 330.0, 300.0)); + assert!( + matches!(a.as_slice(), [Action::Preview(cmd, p)] if cmd == "object.objectsOnPath.moveMember" && *p == json!({ "group": g.0, "member": ids[1].0, "target": ids[2].0 })), + "{}: {a:?}", + t.id() + ); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 600.0, 300.0)).is_empty(), "{}: beyond the end, the last place", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 600.0, 300.0)), vec![Action::Commit, choose(ids[1])], "{}", t.id()); + assert!(!t.busy() && !t.transforming()); + // The last one, back before the start of the line. + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[1].0), "{}: {a:?}", t.id()); + let a = t.pointer(&c, &ev(PointerKind::Drag, -80.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{}: {a:?}", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, -80.0, 300.0)), vec![Action::Commit, choose(ids[2])], "{}", t.id()); + } + } + + /// A drag that comes back to its own place previews the object back there and keeps nothing on + /// release; Escape cancels a drag. + #[test] + fn a_select_move_drag_back_to_its_place_or_escaped_keeps_nothing() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 190.0, 300.0))), Some(ids[1].0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 20.0, 300.0))), Some(ids[0].0), "{}: back onto itself", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 20.0, 300.0)), vec![Action::Cancel, choose(ids[0])], "{}", t.id()); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 0.0, 300.0)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + + /// Round a closed path the drag goes on across the first anchor: the first object, dragged back + /// past it, takes the last place. + #[test] + fn a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor() { + // The square's outline is 400 pt round, from (100, 100) to the right and on clockwise: the + // four objects sit on its corners. + let (d, _, sel, ids) = members_doc(shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 130.0))).is_none(), "nearer its own place"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 180.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 180.0)), vec![Action::Commit, choose(ids[0])]); + // Forward from the last one across the first anchor: the first place. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 200.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 120.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); + } + + /// A hidden or locked object has no widget, and a drag does not take its place: the first + /// object, dragged onto the locked last one's place, stays at the nearest place it can take. + #[test] + fn a_locked_object_has_no_select_move_widget_and_keeps_its_place() { + let (mut d, _, sel, ids) = three_doc(); + d.node_mut(ids[2]).unwrap().locked = true; + assert_eq!(MemberWidget::all(&d, &sel, 1.0).iter().map(|w| w.member).collect::>(), ids[..2]); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 400.0, 300.0)).iter().all(|a| *a != choose(ids[2])), "no widget to press"); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))), Some(ids[1].0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 300.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Of widgets on the same point (no Space between the objects), a press takes the chosen + /// object's, else the first one's; between places as near, a drag keeps the place it is at. + #[test] + fn stacked_select_move_widgets_take_the_chosen_one_and_ties_keep_the_place() { + let (d, _, mut sel, ids) = members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, Some(0.0)); + let paint = paint(); + { + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[0])]); + // Every place is as far as the others: the drag stays where it is. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 300.0))).is_none()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 300.0)), vec![Action::Cancel, choose(ids[0])]); + } + sel.on_path_member = Some(ids[2]); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..3af4a84ea7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,9 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; +use crate::objects_on_path::{ + MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, +}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -135,6 +137,8 @@ enum State { Space(SpaceDrag), /// Dragging an Objects on Path group's Move All widget. MoveAll(MoveAllDrag), + /// Pressing or dragging the Select/Move widget of an object of an Objects on Path group. + Member(MemberDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -753,7 +757,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -845,6 +849,7 @@ impl Tool for SelectionTool { State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), + State::Member(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -1042,6 +1047,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::Member(mut d)) => { + let out = d.drag(cx, p); + self.state = State::Member(d); + out + } + (PointerKind::Up, State::Member(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -1110,6 +1124,9 @@ impl Tool for SelectionTool { State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } + if let (Some(p), State::Idle) = (self.bracket_hover, &self.state) { + o.extend(member_hover(cx, p)); + } o.extend(self.guides.iter().cloned()); o.extend(self.guide.overlays(cx)); if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) { @@ -1158,7 +1175,7 @@ impl Tool for SelectionTool { State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, - State::MoveAll(_) => return Cursor::Move, + State::MoveAll(_) | State::Member(_) => return Cursor::Move, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1184,7 +1201,7 @@ impl Tool for SelectionTool { if over_space(cx, p) { return Cursor::ArrowHollow; } - if over_move_all(cx, p) { + if over_move_all(cx, p) || over_member(cx, p) { return Cursor::Move; } if over_bracket(cx, p) { @@ -1536,7 +1553,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1558,6 +1575,10 @@ impl SelectionTool { self.state = State::MoveAll(d); return Some(vec![]); } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.state = State::Member(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..88c573c5e4 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,14 +238,15 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path -/// group selected). The Rotate All widget is joined by a line to the first object's attachment -/// point. It stays while dragged. +/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one +/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's +/// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; if !matches!(tool, "selection" | "objectsOnPath") { return; } + member_widgets(p, xf, doc, selection); if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -256,6 +257,23 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d move_all_widget(p, xf, doc, selection); } +/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen +/// object. They are drawn under the other widgets, which a press takes first. +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; + for w in MemberWidget::all(doc, selection, xf.zoom) { + let c = xf.to_screen(w.at); + if w.chosen { + p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); + } else { + p.circle(c, MEMBER_RADIUS_PX, Color32::TRANSPARENT, Stroke::new(1.5, c32(doc.layer_color(w.group)))); + } + } +} + +/// The fill of the chosen object's Select/Move widget. +pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); + /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index e84d5cd076..6b9f5f1d22 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4898,6 +4898,38 @@ fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room() assert_eq!(widgets(&mut app), 1, "one object"); } +/// A selected Objects on Path group shows a Select/Move widget on each object's attachment point +/// with the Selection and Objects on Path tools, the chosen object's filled blue. +#[test] +fn a_selected_objects_on_path_group_shows_a_select_move_widget_per_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + let c = id(app.session.execute("shape.rectangle", &json!({"x": 120, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap(); + let ctx = egui::Context::default(); + let r = vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + let widgets = |app: &mut VectorcraftApp, fill: Color32| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(k) if k.radius == r && k.fill == fill)).count() + }; + let (ring, chosen) = (Color32::TRANSPARENT, super::overlays::MEMBER_CHOSEN); + app.select_tool("selection"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (3, 0)); + app.run("object.objectsOnPath.selectMember", json!({"member": b})).unwrap(); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1), "the chosen one is filled"); + app.select_tool("objectsOnPath"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1)); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0, "an object in the group, not the group"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..53a2d03091 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8873,6 +8873,30 @@ value the open path is too short for sets the distance, the drag also sets `spac objects stay that far apart. Shift and Alt do not change the drag. With measurement labels on, the first object's distance along the path shows next to the pointer in the document's units. Escape cancels the drag. +The same tools show a Select/Move widget, a small ring, on each object's attachment point; the ring under the pointer +is highlighted. A click on one chooses its object: it sends `object.objectsOnPath.selectMember {member}`, which +selects the group alone with the object chosen, and the chosen object's ring is filled blue. Any other selection +change clears the choice. A click on the object's art away from the ring selects the group as before. Dragging a ring +along the path moves its +object to the place of the object whose attachment point is nearest the pointer along the path, and previews +`object.objectsOnPath.moveMember {group, member, target}`; the release keeps it as one undo step and chooses the +object. A drag that ends on the object's own place, and Escape, keep nothing. On an open path a pointer beyond an end +takes the place at that end; on a closed path the drag goes on round across the first anchor. Where two places are as +near, the drag stays at the place it is at. A hidden or locked object has no widget, and a drag does not take or +shift its place. Shift and Alt do not change the drag. The widgets for Rotate All, Space and +Move All take a press before a Select/Move widget, and of Select/Move widgets on the same point a press takes the +chosen one, else the first along the path. + +`object.objectsOnPath.moveMember {member, target, group?}` moves `member` to `target`'s place along the path: the +objects between them each move one place toward where `member` was, except hidden and locked objects, which keep +their places and are passed over. Both must be objects of the same Objects on Path +group (not its path), and neither they nor the group may be hidden or locked; `group`, when given, must be that +group. Only the +order along the path changes: Space, the start, Pivot, Rotate and the children's order in the Layers panel stay. A +`target` that is `member` changes nothing and records no undo step. It returns `{group, member, index}`, the object's +new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` +and returns `{group, member}`. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4b3dd3bb7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1289,9 +1289,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. + Objects on Path Select/Move widget: with one Objects on Path group selected, the Selection and Objects on Path tools show a small ring on each object's attachment point, highlighted under the pointer. A click on one chooses that object and fills its ring blue while the group stays selected (`object.objectsOnPath.selectMember`); a click on the object's art still selects the group. Dragging a ring along the path moves its object to the object's place nearest the pointer, and the objects between move one place toward where it was, in one undo step (`object.objectsOnPath.moveMember`); hidden and locked objects keep their places; only the order along the path changes, not the Layers panel's order or the Space. On an open path the drag stops at the ends, on a closed path it goes on round across the first anchor, and Escape cancels it ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::moving_an_object_shifts_the_ones_between_it_and_its_new_place` in `crates/doc`, `tests::objects_on_path::move_member_takes_the_target_s_place_and_shifts_the_objects_between` in `crates/engine`, `objects_on_path::tests::{dragging_a_select_move_widget_moves_its_object_to_the_nearest_place, a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_a_select_move_widget_per_object` in `crates/ui-egui`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. From 59bc3677fe251ca0594cfa46f78afa815b35452b Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:26:48 +0430 Subject: [PATCH 16/31] Fix macOS temporary-path expectations in engine tests Canonicalize test roots before path comparisons and case-variant script probes. Production access checks are unchanged.\n\nFixes #6860 --- crates/engine/src/cmd/fileio/tests_screens.rs | 2 +- crates/engine/src/tests/script_io.rs | 2 +- docs/development.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..0b529c87c6 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -730,7 +730,7 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ let settings = json!({"formats": [{"format": "png"}], "folder": parent}); s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); - let root = inside.display().to_string(); + let root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string(); let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index d8c5d1d2e4..667635356c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case"); + let base = folder("case").canonicalize().unwrap(); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..07c0f65a77 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. > **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-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`. @@ -508,6 +509,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Tests (#6860): compare preset refusal roots after canonicalization and Windows display normalization; canonicalize the script fixture base before constructing `MAIN`. This keeps macOS `/var` → `/private/var` aliases from masking the case-sensitivity checks; no production-code change. | | 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 28dfd66b65b30240b14e8f6257c3094d9d9a6eda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:51:42 -0800 Subject: [PATCH 17/31] Images: a quarter turn by the orientation swaps the resolution, and every TIFF follows the tag Opening or placing an image whose orientation turns it a quarter (EXIF or TIFF Orientation 5 to 8) stored the pixels turned but kept the resolution as stored, so a photo at 300 x 150 ppi came in at the wrong width and height. XResolution counts pixels in the ImageWidth direction of the stored image (TIFF 6.0, https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf; EXIF uses the same tag, CIPA DC-008-2012, https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf), so a quarter turn now swaps the axes, in the size Open and Place use and in the stored image, with jxlread::oriented_resolution as JPEG XL does. Three kinds of TIFF did not follow their Orientation tag: - grayscale TIFFs with extra samples and 1-bit CCITT Group 3 TIFFs were not turned; - a CMYK TIFF with its inks together was turned but stored as RGB, losing its inks, and one with a plane per ink did not open ("Planar TIFF with CMYK color type is not supported"). They now turn as the other images do, and a CMYK TIFF keeps its inks, 8 bits each, written again with the turned resolution. --- crates/doc/src/cmyk.rs | 9 +- crates/engine/src/cmd/fileio/load.rs | 52 +++++-- crates/engine/src/cmd/fileio/tests.rs | 207 ++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e487226d4f..a3a6f7b016 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco Some((d.colortype().ok()?, d)) } +/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images +/// and TIFFs without one. Reads only its header. +pub fn tiff_orientation(bytes: &[u8]) -> Option { + let (_, mut d) = tiff(bytes)?; + d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -299,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -fn tiff_inks(bytes: &[u8]) -> Option { +pub fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..7df402724a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -814,6 +814,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. +/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK TIFF +/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -852,7 +855,15 @@ pub fn raster_image(bytes: &[u8]) -> Result { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let turned = orientation.is_some(); + // The resolution describes the stored pixels; a quarter turn swaps its axes. + let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); + if let Some(orientation) = orientation.filter(|_| f.id == "tiff") + && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + { + return stored; + } let kept = match f.id { _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), @@ -868,36 +879,53 @@ pub fn raster_image(bytes: &[u8]) -> Result { vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { - Some(img) => img, + Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { - Some(read) => read.map_err(err)?, + Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let mut decoder = reader.into_decoder().map_err(err)?; + let decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } - let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); - let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?; - img.apply_orientation(orientation); - img.to_rgba8() + image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; + img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); + let mut img = img.into_rgba8(); if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, None, ppi) + as_png(img, None, shown_ppi) } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image +/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { use image::ImageDecoder as _; - let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; - decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms) + use image::metadata::Orientation; + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +} + +/// CMYK TIFF `bytes` turned or flipped by their `orientation`, as [`raster_image`] stores them: +/// their ink amounts ([`vectorcraft_doc::cmyk::tiff_inks`], 8 bits each) turned and written again +/// by [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels. `None` for other +/// images and for CMYK TIFFs whose inks it doesn't read. +fn turned_cmyk_tiff(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option> { + let inks = vectorcraft_doc::cmyk::tiff_inks(bytes)?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c90e077eb4..5f508bd6ec 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -907,6 +907,213 @@ fn a_photo_places_the_way_its_exif_orientation_says() { assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } +/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as +/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right, +/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it. +fn first_pixel_at(orientation: u16) -> (bool, bool) { + match orientation { + 2 | 6 => (true, false), + 3 | 7 => (true, true), + 4 | 8 => (false, true), + _ => (false, false), + } +} + +/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP +/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution +/// of `x` and `y` pixels per inch. +fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec { + let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat(); + // Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow. + [ + b"II".as_slice(), + &[0x2a, 0, 8, 0, 0, 0], + &4u16.to_le_bytes(), + &entry(0x0112, 3, u32::from(orientation)), + &entry(0x011a, 5, 62), + &entry(0x011b, 5, 70), + &entry(0x0128, 3, 2), + &[0, 0, 0, 0], + &[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(), + ] + .concat() +} + +/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's +/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation +/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned +/// a second time. Values outside 1 to 8 leave the photo as stored. +#[test] +fn a_quarter_turn_swaps_the_resolution_axes() { + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white. + let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + // Without a JFIF density the resolution comes from the EXIF block. + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored"); + let quarter = (5..=8).contains(&orientation); + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let img = raster_image(&turned).unwrap(); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80; + let (w, h) = size; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let reds: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect(); + assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again"); + } +} + +/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a +/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many +/// pixels per inch: its first directory copied to the end of the file with the tag added in order, +/// and the header pointing there, so that every other offset in the file still holds. +fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option) -> Vec { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n); + let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) }; + entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat()); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + if let Some(y) = y_ppi { + let rational = u32::try_from(out.len()).unwrap(); + out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat()); + let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap(); + e[8..12].copy_from_slice(&rational.to_le_bytes()); + } + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed +/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284), +/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes. +#[test] +fn tiffs_read_without_the_image_decoder_follow_their_orientation() { + // 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque. + let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]); + let rows: Vec> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect(); + let g = vectorcraft_testkit::tiff::Group3 { + rows: &rows, + compression: 2, + t4_options: 0, + photometric: 0, + fill_order: 1, + rows_per_strip: 2, + tile: None, + rtc: false, + }; + let fax = vectorcraft_testkit::tiff::group3(&g); + // The Group 3 file declares no resolution. + for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] { + assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now"); + for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) { + let what = format!("{kind}, orientation {orientation}, LONG {long}"); + let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) }); + assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner"); + } + } +} + +/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores +/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes. +/// Read again, the stored image is not turned a second time. +#[test] +fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { + // 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black. + let first: [u8; 4] = [255, 0, 0, 0]; + let other: [u8; 4] = [0, 0, 0, 77]; + let channel = |i: usize| -> Vec { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() }; + for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] { + let channels: Vec> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let file = hand_tiff(5, bits, &[], planar, &channels); + for orientation in 1..=8 { + let what = format!("{bits} bits, planar {planar}, orientation {orientation}"); + let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) }; + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + for (i, px) in inks.data.chunks(4).enumerate() { + let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0); + assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})"); + } + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } + } +} + +/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read +/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a +/// half turn keeps them. +#[test] +fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { + use image::ImageEncoder as _; + let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] })); + for orientation in [3u16, 6, 8] { + let mut png = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut png); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let png = super::ppi::with_png_resolution(&png, (150.0, 300.0)); + let mut webp = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let channel = |c: usize| -> Vec { rgb.pixels().map(|p| u16::from(p[c])).collect() }; + let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300)); + for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] { + let what = format!("{kind}, orientation {orientation}"); + assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [at], "{what}: the first stored pixel's corner"); + } + } +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] From a5d0fe7963b726908daf72d9485cb510fac21cab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:20 -0800 Subject: [PATCH 18/31] Tests: font menu and menu bar tests allow for fonts and plug-ins other tests change In a full parallel `cargo test -p vectorcraft-ui-egui` run, other tests add and remove composite fonts in the process-wide font database and remove plug-ins from the plug-in registry. Two tests failed when that happened between their reads, and passed when run alone. - `font_menu::tests::the_scroll_bar_leaves_the_stars_clear` starred `FontDb::global().families()`, but the menu lists the composite fonts first, so the top rows had no star. The test now turns on the starred filter and counts the rows it listed. - `tests::nativemenu::the_menu_bar_is_inert_while_a_file_dialog_is_open` required every menu row from before the dialog to be back afterwards. A row may now be missing only when the composite font or plug-in it names is gone from the database or the registry. - Four font menu tests took the first families from `menu_family_list()`, which can be a composite font that is removed before the frames run. They take them from the families that stay listed. The variable-filter test checks that every family listed before the click is listed again, instead of comparing two frames. --- crates/ui-egui/src/font_menu/tests.rs | 26 +++++++++++++++++-------- crates/ui-egui/src/tests/nativemenu.rs | 27 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index a054794379..259408711c 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -21,12 +21,13 @@ fn the_thai_filter_lists_fonts_with_thai() { /// stay clear of it. #[test] fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. + // Every family starred, so each row draws its star. Tests running alongside add fonts and + // composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps + // the rows to the families starred here. let all = vectorcraft_text::FontDb::global().families(); - let families = all.len(); let ctx = egui::Context::default(); let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); - let mut st = super::MenuState::default(); + let mut st = super::MenuState { favorites_only: true, ..Default::default() }; let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; let mut stars = vec![]; for _ in 0..3 { @@ -45,6 +46,7 @@ fn the_scroll_bar_leaves_the_stars_clear() { }) .collect(); } + let families = st.rows.len(); assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); // The rows don't fill the list without enough families to scroll, and then there is no bar. if families as f32 * look.row > area.height() { @@ -62,6 +64,13 @@ fn family(name: &str) -> Row { Row::Family(name.to_string()) } +/// The families the font menu lists that stay listed, in its order: the menu's list without the +/// composite fonts at its top, which tests running alongside define and remove in the process-wide +/// database. +fn lasting_families() -> Vec { + vectorcraft_text::FontDb::global().family_list().iter().filter(|f| !f.starts_with('.')).cloned().collect() +} + #[test] fn recent_fonts_are_first_available_unique_and_expandable() { let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); @@ -86,7 +95,7 @@ fn recent_fonts_are_first_available_unique_and_expandable() { #[test] fn recent_section_rebuilds_and_arrows_skip_its_headings() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -135,7 +144,7 @@ fn recent_section_rebuilds_and_arrows_skip_its_headings() { #[test] fn recent_fonts_obey_search_and_favorite_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -176,7 +185,7 @@ fn recent_fonts_obey_search_and_favorite_filters() { #[test] fn opening_on_another_font_keeps_the_recent_section_visible() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); let Some(current) = names.last() else { return }; let recent = vec![names[0].clone()]; let ctx = egui::Context::default(); @@ -244,7 +253,7 @@ fn the_wheel_steps_one_family_per_notch_and_stops_at_the_ends() { /// others in the database's order, under the favourites (and other) filters. #[test] fn the_wheel_follows_the_menus_order_and_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -770,8 +779,9 @@ fn the_variable_filter_lists_only_variable_families() { assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); st.favorites_only = false; // Off again: every family is listed. + let lasting = lasting_families(); let again = click(&mut st, &favorites); assert!(!st.variable_only); - assert_eq!(again, frame(&mut st, vec![], &[]).0); + assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}"); assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); } diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index 65bd01999d..a5a8ea0d7a 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -69,6 +69,25 @@ fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } +/// Is the font or plug-in that an item runs `command` with `params` for gone from the process-wide +/// font database or plug-in registry? A test running alongside can remove a composite font or a +/// plug-in, and the menus then drop its item. +fn source_gone(command: Option<&str>, params: &Value) -> bool { + use vectorcraft_plugins::{PREFIX, effect, export, registry}; + let field = |key| params.get(key).and_then(Value::as_str); + let plugin_gone = |id: Option<&str>| id.is_some_and(|id| registry::get(id).is_none()); + match command { + Some("text.setStyle") => field("font").is_some_and(|f| !vectorcraft_text::FontDb::global().menu_family_list_now().iter().any(|x| x == f)), + Some("plugin.dialog") => plugin_gone(field("id")), + Some("file.openWith") => plugin_gone(field("importer").and_then(export::plugin_id)), + Some("effect.apply" | "effect.dialog") => plugin_gone(field("effect").and_then(effect::plugin_id)), + Some(c) if vectorcraft_engine::cmd::plugin::is_command_id(c) => { + plugin_gone(c.strip_prefix(PREFIX).and_then(|rest| rest.rsplit_once('.')).map(|(id, _)| id)) + } + _ => false, + } +} + #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); @@ -722,9 +741,13 @@ fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { m.frame(vec![]); m.frame(vec![]); assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was (tests running alongside may add plug-in rows). + // Every row from before is back as it was. Tests running alongside may add rows, and may + // remove the composite font or plug-in a row is for. let after = rows(&m.fake.last()); - assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before"); + for r in rows(&before) { + let listed = after.iter().any(|a| (a.0, &a.1) == (r.0, &r.1)); + assert!(after.contains(&r) || (!listed && source_gone(r.0, &r.1)), "{r:?} is not as before"); + } m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); From 5a883996775979403374f8507eaa933ffe9a7c2e Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:30:51 +0430 Subject: [PATCH 19/31] Normalize Windows verbatim prefix in script path regression --- crates/engine/src/tests/script_io.rs | 2 +- docs/development.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 667635356c..e056ea2f7c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case").canonicalize().unwrap(); + let base = crate::cmd::findfiles::plain(folder("case").canonicalize().unwrap()); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/docs/development.md b/docs/development.md index 07c0f65a77..79b10e1593 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots and normalize Windows verbatim prefixes before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. > **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-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`. From 842f1f1b68e5411e35c51c8089ce146125d9f0f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:01:14 -0800 Subject: [PATCH 20/31] 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 { // 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>", "" ]) .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> { - 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, unicode: Box, high_bit: bool, space: Option }, - /// 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 { - 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; - 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`: ). -fn unicode_cmap(name: &str) -> Option { - 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> { - 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` -/// (): 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> { - 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 { - 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 -/// (); a CMap's vertical form has its -/// horizontal form's. `None` for a CMap not listed here. -fn code_space(cmap: &str) -> Option { - 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 { - 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], vals: &[V]) { let mut code: Option = None; @@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option], 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(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 { (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, Option>)>, pub g: GState, pub saved: Vec, 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 () +//! 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, unicode: Box, high_bit: bool, space: Option }, + /// 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 { + 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)) { + 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; + 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) { + 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 { + 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`: ). +fn unicode_cmap(name: &str) -> Option { + 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)) { + 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> { + 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` +/// (): 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> { + 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 { + 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 +/// (); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option { + 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 { + 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 { + 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: ), 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::>(), [*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 show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中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::>(), ["日本"]); + 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::(), "日本"); + 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::>(), ["日本"], "{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::>(), ["fi"], "{show}"); + t[0].runs.iter().map(|r| r.style.features.clone()).collect::>() + }; + 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::>(), ["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 { - 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, ); 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 { 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 = 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> = match &plain { + let chars = match &plain { Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::>(), 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> = 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 = 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 = 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::()) / 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 = 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::(); + target.extend(before); + target.push(rest); + } else if let Some(last) = target.last_mut() { + *last += along; + } else { + lead += *d; } } let tracking: Vec = 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> { - 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> { + 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> { + 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 { + 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::() / 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 a1cbb852b38861278869df67c24992bd7550eb0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 02:05:08 -0800 Subject: [PATCH 21/31] Objects on Path: a Select/Move drag takes the place nearest the pointer on a path that turns back The drag chose its place from the point of the path nearest the pointer, but counted each point as farther by half its distance along the path from the last one. On an open path that turns back on itself, the object then stayed at its place while the pointer was over another object's ring 10 pt away. The drag now uses the point nearest the pointer. Where the path passes as near the pointer more than once, as where it runs back over itself, the drag follows the part nearest along the path to where it was. --- crates/tools/src/objects_on_path.rs | 53 +++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index a60c9fb15a..8f19259126 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -654,7 +654,9 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option { /// pointer, and the objects between it and its place move one place toward where it was, except /// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond /// an end takes the place at that end; on a closed path the pointer goes on round across the first -/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// anchor. Where the path passes as near the pointer more than once, as where it runs back over +/// itself, the drag follows the part nearest along the path to where it was. Where two places are +/// as near, the one the drag is at stays. A click without a drag, and /// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). #[derive(Clone, Debug)] pub struct MemberDrag { @@ -671,7 +673,7 @@ pub struct MemberDrag { closed: bool, start: Point, began: bool, - /// The distance along the path to the point nearest the pointer at the last drag (points). + /// The distance along the path the drag was last at (points). last: f64, /// The place the drag puts the object at. target: usize, @@ -732,7 +734,14 @@ impl MemberDrag { out.push(Action::Begin("Move Object on Path".into())); } let w = &self.widget; - let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some((near, t)) = spine.nearest(p, None) else { return out }; + // Where the path passes as near the pointer more than once, the point nearest along the path + // to where the drag was counts. + let t = match spine.nearest(p, Some(self.last)) { + Some((d, s)) if d - spine.apart(s, self.last) / 2.0 - near <= 1e-9 * (1.0 + near) => s, + _ => t, + }; if !t.is_finite() { return out; } @@ -1872,6 +1881,44 @@ mod tests { assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); } + /// On an open path that turns back on itself, a drag takes the place nearest the pointer even + /// when that place is far along the path from where the drag is. + #[test] + fn a_select_move_drag_takes_the_place_under_the_pointer_on_a_path_that_turns_back() { + // The path runs 210 pt from (0, 0) right, down and back left to (0, 10): the four objects + // sit at 0, 70, 140 and 210 pt along it, the last one 10 pt below the first. + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 10.0), Point::new(0.0, 10.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 0.0, 9.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 9.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Where an open path runs back over itself, a drag along the part that runs back stays on that + /// part. + #[test] + fn a_select_move_drag_follows_the_part_it_is_on_where_a_path_runs_back_over_itself() { + // The path runs 540 pt from (0, 0) right to (300, 0) and back left to (60, 0): the four + // objects sit at 0, 180, 360 and 540 pt along it, at x 0, 180, 240 and 60. + let corners = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(60.0, 0.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 240.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 220.0, 0.0)); + assert!(previewed_target(&a).is_none(), "nearer its own place: {a:?}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 0.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 0.0)), vec![Action::Commit, choose(ids[2])]); + } + /// A hidden or locked object has no widget, and a drag does not take its place: the first /// object, dragged onto the locked last one's place, stays at the nearest place it can take. #[test] From 438a39fdf4577eb2af46675532851442fe8036e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:09:18 -0800 Subject: [PATCH 22/31] Render: blend steps' drop shadows draw under an opacity mask with Clip off An object with an opacity mask draws offscreen over the bounds the renderer gives it. For a live object (a blend, envelope or repeat) those bounds took in effects in symbol art and brush art inside it, but not a path's own effects, so with Clip off the drop shadows of a blend's steps were cut off at the steps' bounds and did not draw where the mask art leaves them uncovered. The bounds now also take in how far each path's own effects paint past it. --- crates/render/src/fx/bounds.rs | 20 ++++++++++++++--- crates/render/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 9d5e9c0d1a..4b5055e67f 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that -/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the -/// farthest side. 0 for any other kind of object. +/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far +/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any +/// other kind of object. fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { if !vectorcraft_doc::live::is_live(n) { return 0.0; } let mut reach = 0.0f64; - n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c))); + n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c))); reach } +/// How far the effects of path or compound path `n` (a drop shadow, a glow, a geometry effect) +/// paint past its visual bounds, on its farthest side ([`visual_bounds`]): 0 when it has no +/// visible effects, and for any other kind of object. +fn path_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { + if !has_fx(n) { + return 0.0; + } + match (visual_bounds(symbols, n), n.visual_bounds_with(symbols)) { + (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + } +} + /// How far the effects in the art of symbol instance `n` (and in the art of the symbols the /// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the /// art as the instance places it before its effects run: 0 when the art has no visible effects, and diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 83f6131071..9cf4ac9ca0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1260,6 +1260,45 @@ fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() { assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask"); } +/// The drop shadows of a blend's steps draw under an opacity mask with Clip off, where the mask +/// art leaves them uncovered, as they draw without the mask; with Clip on they are hidden. +#[test] +fn blend_step_shadows_draw_under_an_unclipped_opacity_mask() { + use vectorcraft_doc::live::{BlendSpacing, BlendSpec}; + use vectorcraft_doc::{NodeKind, OpacityMask}; + + let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 15, "y": 15, "blur": 0}), visible: true }; + let blend = |d: &mut Document, mask: Option| { + let end = |d: &mut Document, x: f64| { + let mut n = rect_node(d, Rect::new(x, 10.0, x + 10.0, 20.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + n.appearance.effects.push(shadow.clone()); + Arc::new(n) + }; + let ends = vec![end(d, 10.0), end(d, 70.0)]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + if let Some(clip) = mask { + let white = rect_node(d, Rect::new(0.0, 0.0, 5.0, 5.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, clip))); + } + n + }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let draw = |mask: Option| { + let mut d = Document::new(120.0, 60.0); + let n = blend(&mut d, mask); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + Renderer::new().render(&d, 120, 60, Affine::IDENTITY, &opts) + }; + let (plain, unclipped, clipped) = (draw(None), draw(Some(false)), draw(Some(true))); + for (x, y) in [(30, 30), (90, 32)] { + assert!(plain.pixel(x, y)[0] < 128, "the shadow draws at ({x}, {y}) without a mask: {:?}", plain.pixel(x, y)); + assert_eq!(unclipped.pixel(x, y), plain.pixel(x, y), "the shadow draws at ({x}, {y}) under the unclipped mask"); + assert_eq!(clipped.pixel(x, y), [255, 255, 255, 255], "the clipped mask hides the shadow at ({x}, {y})"); + } + assert_eq!(unclipped.pixel(15, 15), plain.pixel(15, 15), "the first end draws under the unclipped mask"); +} + /// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 /// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and /// still draw where their masks show them. From 366d09d28546d8250ec6dcd60b7b39cb75cd4972 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:11 -0800 Subject: [PATCH 23/31] Save As: a save goes ahead when the portal answers with a file type of another shape The save dialog read the portal's current_filter with zvariant's tuple conversion, which panics on a structure with fewer fields than the tuple. On Linux and the BSDs the dialog's thread then ended after the user picked a file, and nothing was saved. The label is now read only from a (sa(us)) structure, the shape the FileChooser portal defines. An answer of another shape keeps the path and names no file type, as an answer without current_filter does. A structure with more fields than (sa(us)) also no longer names a file type. --- apps/vectorcraft/src/portal_save.rs | 52 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 0357829aec..930938e03f 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -102,13 +102,22 @@ fn answer(code: u32, results: &HashMap) -> Result) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = results.get("current_filter").and_then(|v| filter_label(v)); Ok(Some(Saved { path, filter })) } +/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of +/// another shape. +fn filter_label(value: &Value<'_>) -> Option { + match value { + Value::Structure(s) => match s.fields() { + [Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()), + _ => None, + }, + _ => None, + } +} + /// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere /// (`file://host/`) or another scheme. fn file_uri_path(uri: &str) -> Option> { @@ -396,6 +405,36 @@ mod tests { assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]); } + /// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more + /// fields, a string) gives the path alone; `uris` of another shape is an error. + #[test] + fn an_answer_of_another_shape_is_not_read() { + let malformed = [ + Value::from(("SVG".to_string(),)), + Value::from(("SVG".to_string(), vec![(0u32,)])), + Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])), + Value::from((1u32, vec![(0u32, "*.svg".to_string())])), + Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)), + Value::from("SVG"), + ]; + for filter in malformed { + let mut r = results("file:///tmp/card", None); + r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap()); + assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None }))); + } + let malformed = [ + Value::from(vec![1u32]), + Value::from(vec![("file:///tmp/card".to_string(),)]), + Value::from(Vec::::new()), + Value::from("file:///tmp/card"), + ]; + for uris in malformed { + let mut r = HashMap::new(); + r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap()); + assert!(answer(0, &r).is_err()); + } + } + /// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file /// is an error, and rfd shows the dialog instead. #[test] @@ -486,10 +525,7 @@ mod tests { } let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap)>() else { break }; let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); - let filter = options.get("current_filter").and_then(|v| { - let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = options.get("current_filter").and_then(|v| filter_label(v)); let calls = log.lock().map(|mut l| { l.push((text("current_name"), filter)); l.len() From 6849bd54222c485e12244985f2ef7f1ddb85d46c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:12:10 -0800 Subject: [PATCH 24/31] 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.`, 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.`, 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.", "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.", "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:`); 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 `: `, 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.` 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 From 1bf09bf72e680ae7b0f3a278a37c8917dbb022b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:24:22 -0800 Subject: [PATCH 25/31] SVG import: a of a text passes its text properties on to the text The element a shows inherits from the rather than from where it is defined (SVG 2, https://www.w3.org/TR/SVG2/struct.html#UseElement). A text shown by a now takes the font, size, weight, fill, stroke and other text properties the or its parents set, in clip paths too, while the text's own properties still win. Uses with the same parent and properties share one text. A inside the text it shows is ignored for inheritance, and texts built for elements stop at 1 MiB of text with a warning. --- crates/svg/src/import/text.rs | 299 +++++++++++++++++++++++++---- crates/svg/tests/it/clip_text.rs | 14 ++ crates/svg/tests/it/import_text.rs | 62 ++++++ 3 files changed, 338 insertions(+), 37 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 52b6663906..afd719163f 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -247,8 +247,9 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; - // The slot of each text, by element, for the `` elements in clip paths below. + // The slot of each text, by element, for the `` elements below. let mut slot_of = HashMap::new(); + let mut inst = Instances::default(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): @@ -275,20 +276,22 @@ pub(super) fn prepare<'s>( // A `` elsewhere can still show the text by its id: the clip path leaves out this // group (a group isn't clip path content), but the `` finds it. if t.attribute("id").is_some() { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } } else { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } copied = r.end; edits.push((r, out)); slot_of.insert(t.id(), index); slots.texts.push(text); + inst.paints.push(paints); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); + text_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); edits.sort_by_key(|(r, _)| r.start); let mut out = String::with_capacity(svg.len()); let mut copied = 0; @@ -332,6 +335,7 @@ fn clip_uses( cx: &Ctx, slot_of: &HashMap, slots: &mut TextSlots, + inst: &mut Instances, edits: &mut Vec<(Range, String)>, warnings: &mut Vec, ) { @@ -343,18 +347,16 @@ fn clip_uses( for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; let Some(t) = ids.get(id) else { continue }; - let Some(&i) = slot_of.get(&t.id()) else { continue }; - let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; let Some(q) = qualifier(svg, u) else { continue }; // An undisplayed `` adds nothing to the clip (usvg leaves it out as it is). if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { continue; } let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); - let em = cx.font_size(style_u); - let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); - let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); - let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let i = style_t.map_or(shared, |s| use_slot(cx, style_xml, slots, inst, shared, s, style_u, warnings)); + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let placed = use_placement(cx, u, style_u); let text_xf = match style_t { Some(s) => own_transform(&cx.css, s), None => t.attribute("transform").unwrap_or("").to_string(), @@ -386,6 +388,180 @@ fn clip_uses( } } +/// Replace each `` outside clip paths that shows a text it passes other properties on to by a +/// group holding that text's placeholder as the `` shows it ([`use_slot`]). Every other +/// `` of a text shows the text's own placeholder. +#[allow(clippy::too_many_arguments)] +fn text_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap, + slots: &mut TextSlots, + inst: &mut Instances, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + let is_use = |n: &XNode| { + n.is_element() + && n.tag_name().name() == "use" + && n.tag_name().namespace() == Some(SVG_NS) + && !n.ancestors().any(|a| a.is_element() && a.tag_name().name() == "clipPath") + }; + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (u, style_u) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)) { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; + let Some(style_t) = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + let i = use_slot(cx, style_xml, slots, inst, shared, style_t, style_u, warnings); + let (Some(text), Some(paints)) = (slots.texts.get(i), inst.paints.get(i)) else { continue }; + if i == shared { + continue; + } + let mut out = String::new(); + use_group(&mut out, q, cx, u, style_u); + placeholder(&mut out, q, &cx.css, *t, style_t, &slots.prefix, i, text.marker, paints, false); + let _ = write!(out, ""); + edits.push((u.range(), out)); + } +} + +/// A text's slot, the parent of a `` showing it and the properties that `` sets itself. +type UseKey = (usize, Option, Vec<(String, String)>); + +/// The slots made for texts as `` elements show them ([`use_slot`]). +#[derive(Default)] +struct Instances { + /// The `url(#…)` paints of each slot, by index. + paints: Vec>, + /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. + memo: HashMap>, + /// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`]. + work: usize, +} + +/// The most nodes and text bytes [`use_slot`] builds texts from, all `` elements together +/// (the file is untrusted). Past it, a `` shows its text with the text's own properties. +const MAX_INSTANCE_WORK: usize = 1 << 20; + +/// The slot of text `style_t` (slot `shared`) as `` `style_u` shows it. The text inherits +/// from the `` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise +/// the result is `shared`. `` elements with one parent that set the same properties share one. +#[allow(clippy::too_many_arguments)] +fn use_slot( + cx: &Ctx, + style_xml: &roxmltree::Document, + slots: &mut TextSlots, + inst: &mut Instances, + shared: usize, + style_t: XNode, + style_u: XNode, + warnings: &mut Vec, +) -> usize { + // A `` inside the text it shows would make the text inherit from itself. + if style_u.ancestors().any(|a| a == style_t) { + return shared; + } + let placing = |name: &str| matches!(name, "id" | "x" | "y" | "width" | "height" | "href" | "transform" | "style"); + let mut own: Vec<(String, String)> = style_u + .attributes() + .filter(|a| a.namespace().is_none() && !placing(a.name())) + .map(|a| (a.name().to_string(), a.value().to_string())) + .collect(); + own.extend(cx.css.declared(style_u).iter().filter(|(k, _)| k.as_str() != "transform").map(|(k, v)| (format!("style {k}"), v.clone()))); + own.sort(); + let key = (shared, style_u.parent_element().map(|p| p.id()), own); + if let Some(slot) = inst.memo.get(&key) { + return slot.unwrap_or(shared); + } + let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); + let work = if inst.work < MAX_INSTANCE_WORK { + style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::() + } else { + 0 + }; + inst.work = inst.work.saturating_add(work); + if work == 0 || inst.work > MAX_INSTANCE_WORK { + inst.work = MAX_INSTANCE_WORK; + let w = format!( + "more than {} KiB of text shown through elements: the rest show their text without the properties the passes on", + MAX_INSTANCE_WORK >> 10 + ); + if !warnings.contains(&w) { + warnings.push(w); + } + return shared; + } + let shown = cx.instance(style_t, style_u); + let mut found = vec![]; + let built = if style_t.tag_name().name() == "flowRoot" { + shown.build_flow(style_t, &label, &mut found) + } else { + shown.build(style_xml, style_t, &label, &mut found) + }; + let differs = |(text, paints): &(PendingText, Vec)| { + slots.texts.get(shared).is_none_or(|old| old.obj != text.obj || old.servers != text.servers || old.under != text.under) + || inst.paints.get(shared) != Some(paints) + }; + let slot = built.filter(differs).map(|(text, paints)| { + for w in found { + if !warnings.contains(&w) { + warnings.push(w); + } + } + slots.texts.push(text); + inst.paints.push(paints); + slots.texts.len() - 1 + }); + inst.memo.insert(key, slot); + slot.unwrap_or(shared) +} + +/// The transform `` `u` places what it shows with: its own, then its `x`/`y` offset (in its +/// font size). `style_u` supplies its styles. +fn use_placement(cx: &Ctx, u: XNode, style_u: XNode) -> String { + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + format!("{} translate({x} {y})", own_transform(&cx.css, style_u)) +} + +/// Write the start tag of the group that takes `` `u`'s place: its attributes in no namespace +/// other than its link, place and size, and as its `style` the properties rules and its `style` set +/// on it (from `style_u`), then its placement ([`use_placement`]) and its non-inherited properties +/// and `visibility`, so no rule that matches the group changes it. +fn use_group(out: &mut String, q: &str, cx: &Ctx, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}g"); + let placing = |name: &str| matches!(name, "style" | "href" | "x" | "y" | "width" | "height" | "transform"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !placing(a.name())) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + let declared = cx.css.declared(style_u); + let mut props: Vec<_> = declared.iter().filter(|(k, _)| k.as_str() != "transform").collect(); + props.sort(); + let mut style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(style, "transform:{};", use_placement(cx, u, style_u)); + for (p, initial) in [ + ("opacity", "1"), + ("clip-path", "none"), + ("mask", "none"), + ("filter", "none"), + ("mix-blend-mode", "normal"), + ("isolation", "auto"), + ("display", "inline"), + ] { + let _ = write!(style, "{p}:{};", cx.css.own(style_u, p).as_deref().unwrap_or(initial)); + } + let _ = write!(style, "visibility:{}", cx.css.prop(style_u, "visibility").as_deref().unwrap_or("visible")); + let _ = write!(out, " style=\"{}\">", xml_escape(&style)); +} + /// Write a copy of `` `u` in a ``: its attributes in no namespace, its link as `href`, /// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { @@ -455,11 +631,23 @@ fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); } -/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. +/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`, with the text's +/// id when `with_id`. #[allow(clippy::too_many_arguments)] -fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { +fn placeholder( + out: &mut String, + q: &str, + css: &Styles, + t: XNode, + style_t: XNode, + prefix: &str, + index: usize, + b: Rect, + paints: &[String], + with_id: bool, +) { let _ = write!(out, "<{q}g"); - for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"].into_iter().skip(usize::from(!with_id)) { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } @@ -488,11 +676,15 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode /// Computed text properties of the document's elements. struct Ctx { - css: Styles, + css: Rc, /// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]). dpi: f64, /// Viewport size in user units (the base of `x`/`y` percentages). viewport: (f64, f64), + /// For the styles of one `` instance: the element the `` shows and the ``. The + /// shown element inherits from the `` rather than from its parent (SVG 2 §5.6, + /// https://www.w3.org/TR/SVG2/struct.html#UseElement). + host: Option<(roxmltree::NodeId, roxmltree::NodeId)>, sizes: RefCell>, weights: RefCell>, elems: RefCell>>, @@ -501,9 +693,10 @@ struct Ctx { impl Ctx { fn new(xml: &roxmltree::Document, dpi: f64) -> Self { let mut cx = Self { - css: Styles::new(xml), + css: Rc::new(Styles::new(xml)), dpi, viewport: (100.0, 100.0), + host: None, sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), @@ -517,6 +710,37 @@ impl Ctx { cx } + /// The styles of element `shown` as `` `u` shows it. + fn instance(&self, shown: XNode, u: XNode) -> Self { + Self { + css: self.css.clone(), + dpi: self.dpi, + viewport: self.viewport, + host: Some((shown.id(), u.id())), + sizes: RefCell::default(), + weights: RefCell::default(), + elems: RefCell::default(), + } + } + + /// The element `n` inherits from: its parent, or the `` for the element it shows. + fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option> { + match self.host { + Some((shown, u)) if n.id() == shown => n.document().get_node(u), + _ => n.parent_element(), + } + } + + /// Element `n` and the elements it inherits from, nearest first. + fn lineage<'a, 'i>(&self, n: XNode<'a, 'i>) -> impl Iterator> { + std::iter::successors(Some(n), |a| self.parent(*a)) + } + + /// An inherited property: the nearest specified value other than `inherit`. + fn prop(&self, n: XNode, name: &str) -> Option { + self.lineage(n).find_map(|a| self.css.own(a, name).filter(|v| v != "inherit")) + } + /// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative /// to `em` (the font size) and percentages to `pct`. fn length(&self, v: &str, em: f64, pct: f64) -> Option { @@ -559,7 +783,7 @@ impl Ctx { if let Some(s) = self.sizes.borrow().get(&n.id()) { return *s; } - let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); + let parent = self.parent(n).map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent); self.sizes.borrow_mut().insert(n.id(), s); s @@ -570,7 +794,7 @@ impl Ctx { if let Some(w) = self.weights.borrow().get(&n.id()) { return *w; } - let parent = n.parent_element().map_or(400, |p| self.weight(p)); + let parent = self.parent(n).map_or(400, |p| self.weight(p)); let w = match self.css.own(n, "font-weight").as_deref() { Some("normal") => 400, Some("bold") => 700, @@ -594,12 +818,12 @@ impl Ctx { /// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve. fn paint(&self, n: XNode, prop: &str) -> (Paint, Option) { let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None }; - let Some(v) = self.css.prop(n, prop) else { return (initial, None) }; + let Some(v) = self.prop(n, prop) else { return (initial, None) }; match svgtypes::Paint::from_str(&v) { Ok(svgtypes::Paint::None) => (Paint::None, None), Ok(svgtypes::Paint::Color(c)) => (solid(c), None), Ok(svgtypes::Paint::CurrentColor) => { - (self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) + (self.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) } Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)), _ => (initial, None), @@ -613,7 +837,7 @@ impl Ctx { } let css = &self.css; let mut st = CharStyle::default(); - if let Some(f) = css.prop(n, "font-family") { + if let Some(f) = self.prop(n, "font-family") { let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string(); if !fam.is_empty() { st.font_family = fam; @@ -621,45 +845,46 @@ impl Ctx { } let size = self.font_size(n); st.size = size; - let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); + let italic = self.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); st.font_style = style_name(self.weight(n), italic); // Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited. let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k)); let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v)); - st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default(); + st.font_axes = self.lineage(n).find_map(own).unwrap_or_default(); let (fill, mut fill_url) = self.paint(n, "fill"); st.fill = fill; let (stroke, mut stroke_url) = self.paint(n, "stroke"); if !stroke.is_none() || stroke_url.is_some() { st.stroke = stroke; - st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); - st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() { + st.stroke_width = self.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); + st.stroke_cap = match self.prop(n, "stroke-linecap").as_deref() { Some("round") => LineCap::Round, Some("square") => LineCap::Square, _ => LineCap::Butt, }; - st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() { + st.stroke_join = match self.prop(n, "stroke-linejoin").as_deref() { Some("round") => LineJoin::Round, Some("bevel") => LineJoin::Bevel, _ => LineJoin::Miter, }; st.stroke_miter_limit = - css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); - let pattern: Vec = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); - let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); + self.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); + let pattern: Vec = + self.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); + let offset = self.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed); } - if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.tracking = v / size * 1000.0; } - if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.kerning = Some(v / size * 1000.0); } - if let Some(d) = css.prop(n, "text-decoration") { + if let Some(d) = self.prop(n, "text-decoration") { st.underline = d.contains("underline"); st.strikethrough = d.contains("line-through"); } @@ -672,11 +897,11 @@ impl Ctx { }; let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) }; st.baseline_shift = parent + own; - if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { + if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { (st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None); } - let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); - let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; + let word_spacing = self.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); + let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; let at = |k| order.iter().position(|o| *o == k); let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill); let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first }); @@ -974,7 +1199,7 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() { + let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { Some("middle") => 0.5, Some("end") => 1.0, _ => 0.0, @@ -987,7 +1212,7 @@ impl Ctx { None } }); - let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1059,7 +1284,7 @@ impl Ctx { } // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first // strong character: pinned (on every paragraph) when that would read as right to left. - if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { + if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs index 07773dd5f8..a2dcb46156 100644 --- a/crates/svg/tests/it/clip_text.rs +++ b/crates/svg/tests/it/clip_text.rs @@ -183,6 +183,20 @@ fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); } +#[test] +fn a_use_of_a_text_in_a_clip_path_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert_eq!(t.runs[0].style.font_style, "Bold"); + assert!(clip_bounds(clip).height() > 50.0, "{:?}", clip_bounds(clip)); +} + #[test] fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { // The text's transform moves its glyphs to x 200…; the ``'s clip keeps x < 250 of its own diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 8b0d0338c6..87fe6cf340 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -191,6 +191,68 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() { assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9); } +/// SVG 2 §5.6 (https://www.w3.org/TR/SVG2/struct.html#UseElement): the element a `` shows +/// inherits from the ``, not from where it is defined. +#[test] +fn a_use_of_a_text_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let t = texts(&d); + assert_eq!(t.len(), 3, "{t:?}"); + let s: Vec = t.iter().map(|t| t.first_style()).collect(); + assert!(close(s[0].size, 100.0, 1e-9), "{}", s[0].size); + assert_eq!(s[0].fill, Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0))); + assert!(close(s[1].size, 12.0, 1e-9), "the defaults, not the 's 30: {}", s[1].size); + assert_eq!(s[1].font_style, "Bold"); + assert_eq!(s[1].fill, Paint::solid(vectorcraft_color::Color::BLACK)); + assert!(close(s[2].size, 20.0, 1e-9), "{}", s[2].size); + assert!(t[0].xf.translation().to_point().distance(Point::new(0.0, 50.0)) < 1e-9, "{:?}", t[0].xf); + assert!(t[1].xf.translation().to_point().distance(Point::new(200.0, 50.0)) < 1e-9, "{:?}", t[1].xf); + assert!(t[2].xf.translation().to_point().distance(Point::new(0.0, 250.0)) < 1e-9, "{:?}", t[2].xf); + // The ``'s opacity stays on the object it becomes. + let faded: Vec<&Node> = art(&d).into_iter().filter(|n| (n.opacity - 0.5).abs() < 1e-6).collect(); + assert_eq!(faded.len(), 1, "{faded:?}"); + // The text's own properties win over the ``'s. + let d = open(r##"HI"##); + let s = only_text(&d).first_style(); + assert!(close(s.size, 20.0, 1e-9), "{}", s.size); + assert_eq!(s.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255))); +} + +/// A `` inside the text it shows does not make the text inherit from itself. +#[test] +fn a_use_inside_the_text_it_shows_opens() { + let d = open(r##"HI"##); + assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d)); +} + +/// Past 1 MiB of text built for `` elements, the rest show their text with its own properties. +#[test] +fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { + let long = "a".repeat(350_000); + let uses: String = (1..=4).map(|i| format!(r##""##, i * 10)).collect(); + let (d, w) = import_with_report(&svg(&format!(r##"{long}{uses}"##))).unwrap(); + let sizes: Vec = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through elements")).count(), 1, "{w:?}"); +} + +/// Many `` elements of one text that inherit the same properties each get it. +#[test] +fn many_uses_of_a_text_in_one_group_all_take_its_properties() { + let uses: String = (0..200).map(|i| format!(r##""##)).collect(); + let d = open(&format!(r##"HI{uses}"##)); + let t = texts(&d); + assert_eq!(t.len(), 200); + assert!(t.iter().all(|t| close(t.first_style().size, 40.0, 1e-9))); + assert!(t[199].xf.translation().to_point().distance(Point::new(199.0, 50.0)) < 1e-9, "{:?}", t[199].xf); +} + #[test] fn text_path_becomes_type_on_a_path() { let d = open( From f317aefa442a8f17c13909b4c6204089dbfab3b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:32:35 -0800 Subject: [PATCH 26/31] Tests: the file-dialog menu test reruns while fonts or plug-ins change `the_menu_bar_is_inert_while_a_file_dialog_is_open` excused a missing menu row when the composite font or plug-in it named was gone at the time of the check. A test running alongside can remove such a font and define it again between the menu read and the check, and the test then failed. It now runs inside `undisturbed`, as the file's other tests that compare menu reads do. When the shortcut, plug-in or font generation changes during a run, the test runs again on a new app. Every row from before the dialog must then be back as it was. --- crates/ui-egui/src/tests/nativemenu.rs | 108 ++++++++++--------------- 1 file changed, 44 insertions(+), 64 deletions(-) diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index a5a8ea0d7a..689ae8a60c 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -69,25 +69,6 @@ fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } -/// Is the font or plug-in that an item runs `command` with `params` for gone from the process-wide -/// font database or plug-in registry? A test running alongside can remove a composite font or a -/// plug-in, and the menus then drop its item. -fn source_gone(command: Option<&str>, params: &Value) -> bool { - use vectorcraft_plugins::{PREFIX, effect, export, registry}; - let field = |key| params.get(key).and_then(Value::as_str); - let plugin_gone = |id: Option<&str>| id.is_some_and(|id| registry::get(id).is_none()); - match command { - Some("text.setStyle") => field("font").is_some_and(|f| !vectorcraft_text::FontDb::global().menu_family_list_now().iter().any(|x| x == f)), - Some("plugin.dialog") => plugin_gone(field("id")), - Some("file.openWith") => plugin_gone(field("importer").and_then(export::plugin_id)), - Some("effect.apply" | "effect.dialog") => plugin_gone(field("effect").and_then(effect::plugin_id)), - Some(c) if vectorcraft_engine::cmd::plugin::is_command_id(c) => { - plugin_gone(c.strip_prefix(PREFIX).and_then(|rest| rest.rsplit_once('.')).map(|(id, _)| id)) - } - _ => false, - } -} - #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); @@ -702,52 +683,51 @@ fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { /// answers, the menus are as before and work again. #[test] fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { - let mut m = Mac::new(true); - m.app.services.dialogs_are_sheets = true; - let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); - m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); - m.app.run("select.none", json!({})).unwrap(); - m.frame(vec![]); let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::>(); - let before = m.fake.last(); - let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; - assert!(system.iter().all(|c| find(&before, c).enabled)); - assert!(m.app.run("file.open", json!({})).is_err()); - assert!(m.app.file_dialog_open()); - m.frame(vec![]); - let inert = m.fake.last(); - for it in inert.items() { - let kept = it.command.is_some_and(|c| system.contains(&c)); - assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); - } - // A click that raced the menu going inert isn't run; Minimize is. - let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); - m.fake.report(native_menu::Event::Click(rulers.1.clone())); - m.frame(vec![]); - assert_eq!(m.app.ui.view.rulers, rulers.0); - m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); - assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); - // A key equivalent that raced it doesn't reach egui. - let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); - k.modifiers.mac_cmd = k.modifiers.command; - m.fake.report(native_menu::Event::Key(k)); - let mut raw = egui::RawInput::default(); - m.app.raw_input_hook(&m.ctx, &mut raw); - assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); - m.key_equivalent("Cmd+A"); - assert_eq!(m.selected(), 0, "⌘A selected nothing"); - // Cancelled: the menus are as before, and work again. - shown.answer(&[]); - m.frame(vec![]); - m.frame(vec![]); - assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was. Tests running alongside may add rows, and may - // remove the composite font or plug-in a row is for. - let after = rows(&m.fake.last()); - for r in rows(&before) { - let listed = after.iter().any(|a| (a.0, &a.1) == (r.0, &r.1)); - assert!(after.contains(&r) || (!listed && source_gone(r.0, &r.1)), "{r:?} is not as before"); - } + // The menus read before the dialog and after it are compared, so the whole run is undisturbed: + // a test running alongside can add or remove a composite font or a plug-in in between. + let (mut m, (before, after, rulers)) = undisturbed(|m| { + m.app.services.dialogs_are_sheets = true; + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); + m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); + m.app.run("select.none", json!({})).unwrap(); + m.frame(vec![]); + let before = m.fake.last(); + let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; + assert!(system.iter().all(|c| find(&before, c).enabled)); + assert!(m.app.run("file.open", json!({})).is_err()); + assert!(m.app.file_dialog_open()); + m.frame(vec![]); + let inert = m.fake.last(); + for it in inert.items() { + let kept = it.command.is_some_and(|c| system.contains(&c)); + assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); + } + // A click that raced the menu going inert isn't run; Minimize is. + let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); + m.fake.report(native_menu::Event::Click(rulers.1.clone())); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, rulers.0); + m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); + assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); + // A key equivalent that raced it doesn't reach egui. + let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); + k.modifiers.mac_cmd = k.modifiers.command; + m.fake.report(native_menu::Event::Key(k)); + let mut raw = egui::RawInput::default(); + m.app.raw_input_hook(&m.ctx, &mut raw); + assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 0, "⌘A selected nothing"); + // Cancelled: the menus are as before, and work again. + shown.answer(&[]); + m.frame(vec![]); + m.frame(vec![]); + assert!(!m.app.file_dialog_open()); + (rows(&before), rows(&m.fake.last()), rulers) + }); + // Every row from before is back as it was. + assert!(before.iter().all(|r| after.contains(r)), "the menus are as before"); m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); From 0b22997201519256aabf5de71c9c33aaf98a9a38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:33:38 -0800 Subject: [PATCH 27/31] Docs: the Dynamic Text entries in docs/mcp.md name point type The Dynamic Text: Block entry says the Contextual Task Bar offers the item for point type too, and that turning it on makes point type area type with Block on. The Dynamic Text entry no longer says point type in the selection is left as it is. --- docs/mcp.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 4a5170de26..4449f69897 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7090,8 +7090,8 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print ### Dynamic Text: Block `type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text, and the Contextual Task Bar for -area type outside a thread) turns Block on or off for the -selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type +area type outside a thread and for point type) turns Block on or off for the +selected area type, and turning it on makes point type area type with Block on (below), as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes Block as text that scales to fill a rectangular box, with settings for the spacing between words and between @@ -7970,8 +7970,8 @@ and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps -its look, in one undo step. Point type in the selection is left as it is, and area type in it takes Block -([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a +its look, in one undo step. Area type in the selection takes Block, and turning it on makes point type in the +selection area type with Block on ([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step From 909f0ff301802754737138f3442d8cd2502e7974 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:43:42 -0800 Subject: [PATCH 28/31] SVG import: the path a text follows counts toward the text limit A text on a path that many elements show was laid out along the whole path once per , and the path was found in the file and parsed again each time, while the 1 MiB limit on texts built for elements counted only the text's nodes and characters. A file with a short text on a very long path and many elements of it could keep the importer busy far past that limit. - The elements of the path a follows now count toward the limit. Past it, the remaining elements show the text with its own properties, and the warning names text paths as well as text. - The path of each is found and parsed once and shared by every that shows its text. Files within the limit import as before. --- crates/svg/src/import/text.rs | 54 +++++++++++++++++++++++++---- crates/svg/src/import/text/tests.rs | 24 +++++++++++++ crates/svg/tests/it/import_text.rs | 23 +++++++++++- 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 crates/svg/src/import/text/tests.rs diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index afd719163f..51bcffa41e 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -442,12 +442,14 @@ struct Instances { paints: Vec>, /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. memo: HashMap>, - /// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`]. + /// The nodes, text bytes and text path elements of the texts built so far, up to + /// [`MAX_INSTANCE_WORK`]. work: usize, } -/// The most nodes and text bytes [`use_slot`] builds texts from, all `` elements together -/// (the file is untrusted). Past it, a `` shows its text with the text's own properties. +/// The most nodes, text bytes and elements of the paths ``s follow that [`use_slot`] +/// builds texts from, all `` elements together (the file is untrusted). Past it, a `` +/// shows its text with the text's own properties. const MAX_INSTANCE_WORK: usize = 1 << 20; /// The slot of text `style_t` (slot `shared`) as `` `style_u` shows it. The text inherits @@ -482,7 +484,11 @@ fn use_slot( } let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); let work = if inst.work < MAX_INSTANCE_WORK { - style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::() + // Each build lays the text out along the path its `` follows, so the path counts too. + style_t + .descendants() + .map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { cx.path_elements(n).saturating_add(1) }) + .fold(0, usize::saturating_add) } else { 0 }; @@ -490,7 +496,7 @@ fn use_slot( if work == 0 || inst.work > MAX_INSTANCE_WORK { inst.work = MAX_INSTANCE_WORK; let w = format!( - "more than {} KiB of text shown through elements: the rest show their text without the properties the passes on", + "more than {} KiB of text and text paths shown through elements: the rest show their text without the properties the passes on", MAX_INSTANCE_WORK >> 10 ); if !warnings.contains(&w) { @@ -688,6 +694,16 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, + /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has + /// no usable one. The styles of a `` instance share it, so each path is found and parsed + /// once however many `` elements show its text. + paths: Rc>>>>, +} + +#[cfg(test)] +thread_local! { + /// How many times [`Ctx::read_text_path`] has read a ``'s path on this thread. + static TEXT_PATH_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } impl Ctx { @@ -700,6 +716,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: Rc::default(), }; let root = xml.root_element(); let dim = |a: &str| root.attribute(a).and_then(|v| cx.length(v, DEFAULT_FONT_SIZE, 0.0)).filter(|v| *v > 0.0); @@ -720,6 +737,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: self.paths.clone(), } } @@ -986,8 +1004,27 @@ impl Ctx { } } - /// The path a `` follows, in the text's user space. - fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + /// The path a `` follows, in the text's user space. It is read from the file once + /// ([`Self::paths`]). + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { + if let Some(known) = self.paths.borrow().get(&tp.id()) { + return known.clone(); + } + let path = self.read_text_path(xml, tp).map(Rc::new); + self.paths.borrow_mut().insert(tp.id(), path.clone()); + path + } + + /// How many elements the path of `` `tp` has, when a text built so far follows it + /// ([`Self::text_path`]); 0 for any other node. + fn path_elements(&self, tp: XNode) -> usize { + self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len()) + } + + /// Find and parse the path `` `tp` follows. + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + #[cfg(test)] + TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1); let mut bp = match tp.attribute("path") { Some(d) => BezPath::from_svg(d).ok()?, None => { @@ -1625,3 +1662,6 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec) { } (out, bytes) } + +#[cfg(test)] +mod tests; diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs new file mode 100644 index 0000000000..1508997dcc --- /dev/null +++ b/crates/svg/src/import/text/tests.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The texts [`prepare`] builds from `body` and how many times it read a ``'s path. +fn prepared(body: &str) -> (usize, u64, Vec) { + let svg = format!(r#"{body}"#); + let xml = roxmltree::Document::parse(&svg).unwrap(); + let mut warnings = vec![]; + let before = TEXT_PATH_READS.get(); + let (_, slots) = prepare(&svg, &xml, None, 96.0, &mut warnings); + (slots.texts.len(), TEXT_PATH_READS.get() - before, warnings) +} + +/// Every `` of a text on a path follows the path the text's own build read. +#[test] +fn uses_of_a_text_on_a_path_read_the_path_once() { + let uses: String = (101..=150).map(|i| format!(r##""##)).collect(); + let (texts, reads, warnings) = + prepared(&format!(r##"Path{uses}"##)); + assert_eq!(texts, 51, "the text and one per : {warnings:?}"); + assert_eq!(reads, 1); + // A path with no usable data is not read again either. + let (texts, reads, _) = prepared(&format!(r##"Path{uses}"##)); + assert_eq!((texts, reads), (51, 1)); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 87fe6cf340..b25dc23678 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -239,7 +239,28 @@ fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { assert_eq!(sizes.len(), 4, "{sizes:?}"); assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); - assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through elements")).count(), 1, "{w:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through elements")).count(), 1, "{w:?}"); +} + +/// The path a text follows counts toward that limit: each `` of the text lays it out along +/// the whole path again. +#[test] +fn uses_of_a_text_on_a_long_path_stop_taking_their_properties_past_the_limit() { + let d = format!("M0 0{}", "h1".repeat(350_000)); + let uses: String = (1..=4).map(|i| format!(r##""##, i * 10)).collect(); + let body = format!(r##"HI{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + let sizes: Vec = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through elements")).count(), 1, "{w:?}"); + // The same text on a short path is within the limit however many `` elements show it. + let uses: String = (1..=300).map(|i| format!(r##""##)).collect(); + let body = format!(r##"HI{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(texts(&d).iter().enumerate().all(|(i, t)| close(t.first_style().size, (i + 1) as f64, 1e-9)), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("shown through elements")), "{w:?}"); } /// Many `` elements of one text that inherit the same properties each get it. From 8187f7ae00cb2731e8d9ced6918b3a8c04f3b0bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:45:26 -0800 Subject: [PATCH 29/31] Images: a CMYK JPEG turned by its EXIF orientation keeps its inks A four-component JPEG whose EXIF orientation turns or flips it went through the image decoder, which reads CMYK JPEGs as RGB, and was stored as an RGB PNG without its inks. It is now stored the way a turned CMYK TIFF is: its inks, 8 bits each, turned and written as a CMYK TIFF with the turned resolution. - vectorcraft_doc::cmyk::turned_cmyk turns the inks of a CMYK JPEG or TIFF, next to stored_cmyk_tiff; tiff_inks is private again. - raster_image swaps the resolution axes inline, as its HEIF branch does. - The links comment and the docs/target-app-parity.md line on EXIF orientation now describe the turned CMYK TIFF. --- crates/doc/src/cmyk.rs | 25 ++++++++++++---- crates/engine/src/cmd/fileio/load.rs | 27 +++++------------- crates/engine/src/cmd/fileio/tests.rs | 41 +++++++++++++++++++++++++++ crates/engine/src/cmd/links.rs | 2 +- docs/target-app-parity.md | 2 +- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index a3a6f7b016..f94f0ec4a8 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -306,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -pub fn tiff_inks(bytes: &[u8]) -> Option { +fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { @@ -316,10 +316,10 @@ pub fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, read_samples(&mut d, n, 4)?) } -/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per -/// ink, which the image decoder doesn't read, their ink amounts written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK -/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and +/// Place store them: as they are, or, when they store one plane per ink, which the image decoder +/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution +/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { let inks = tiff_inks(bytes)?; let (_, mut d) = tiff(bytes)?; @@ -329,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } +/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their +/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned +/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] +/// doesn't read. +pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { + let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) +} + /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7df402724a..7cb5d370bb 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -815,8 +815,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. /// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is -/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK TIFF -/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG +/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -858,11 +858,12 @@ pub fn raster_image(bytes: &[u8]) -> Result { let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); let turned = orientation.is_some(); // The resolution describes the stored pixels; a quarter turn swaps its axes. - let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); - if let Some(orientation) = orientation.filter(|_| f.id == "tiff") - && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); + let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) }); + if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff")) + && let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi) { - return stored; + return stored(tiff, "image/tiff", size, shown_ppi); } let kept = match f.id { _ if turned => None, @@ -914,20 +915,6 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option) -> Option> { - let inks = vectorcraft_doc::cmyk::tiff_inks(bytes)?; - // Four 8-bit inks to a pixel, turned as four 8-bit channels are. - let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); - px.apply_orientation(orientation); - let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) -} - /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its /// colors are in. pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5f508bd6ec..5ea57858cc 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1078,6 +1078,47 @@ fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { } } +/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK +/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and +/// the stored image, read again, is not turned a second time. +#[test] +fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black. + let inks: Vec = + (0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap(); + for orientation in 1..=8u16 { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + file.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (w, h) = size; + let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec(); + let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let cyans: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect(); + assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let black = at(w / 2, h / 2); + assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } +} + /// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read /// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a /// half turn keeps them. diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..41417a4433 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became. + // A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became. let mime = match fileio::format_for_name(g.link.name()) { Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(), _ => img.blob.mime.clone(), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..49037bd59f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1553,7 +1553,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). + - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. From 705503da6d6ce0c54d20713f19611728a8f362c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:02:04 -0800 Subject: [PATCH 30/31] Objects on Path: plug-ins may call Select and Move Object on Path object.objectsOnPath.selectMember and object.objectsOnPath.moveMember change only the document and its selection, as the other Objects on Path commands do, so both join the commands a plug-in may call when its manifest lists them. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..355179c4fc 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -307,7 +307,9 @@ pub(super) const ALLOWED: &[&str] = &[ "object.nudge", "object.objectsOnPath.attach", "object.objectsOnPath.expand", + "object.objectsOnPath.moveMember", "object.objectsOnPath.options", + "object.objectsOnPath.selectMember", "object.path.addAnchorPoints", "object.path.cleanUp", "object.path.divideObjectsBelow", From 2212739c5425e12430ef5632c3e14559557f0730 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:46:02 -0800 Subject: [PATCH 31/31] MCP: plug-in, recovery and script report replies leave out paths outside the read roots A client confined by --automation-read-root saw paths outside its read roots that the person or an earlier request had put in the session: - plugin.list: the plug-in folder last loaded and its failed files; plugin.list and plugin.info: a plug-in's source file; plugin.list, plugin.safeMode and plugin.setEnabled: the file of a plug-in turned off for the next launch. - file.recovery.list and file.recovery.restore: a document's original file; file.recovery.list and file.recovery.save: the recovery folder. - ui.inspect's scriptReport: the files a run wrote and the included file a failed run stopped in. A folder outside the read roots is now null, and a file outside them is named by its file name. plugin.setEnabled {file} takes that name. The restored documents keep their files, and unconfined clients and the app's own UI see the same replies as before. --- crates/engine/src/cmd/plugin.rs | 94 ++++++++++++++++++++--- crates/engine/src/cmd/recovery.rs | 39 ++++++++-- crates/engine/src/cmd/scripts.rs | 44 +++++++++++ crates/engine/src/script/mod.rs | 30 ++++++-- crates/engine/src/tests/plugins.rs | 86 +++++++++++++++++++++ crates/engine/src/tests/recovery.rs | 65 ++++++++++++++++ crates/engine/src/tests/script_console.rs | 28 +++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 44 +++++++++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 11 files changed, 418 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..dfa44a1436 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError { } } +/// Under automation read roots, a reply names a file or folder outside them by its name alone. +fn shown_file(path: &str) -> String { + super::fileio::shown_path(path).into_owned() +} + +/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a +/// file outside the automation read roots is named by its file name. +fn shown_item(item: &safe_mode::Item) -> Value { + let mut v = item.to_json(); + if let Some(file) = item.file.as_deref() { + v["file"] = json!(shown_file(file)); + } + v +} + /// JSON description of an installed plug-in. pub fn describe(p: &Plugin) -> Value { let m = p.manifest(); let mut v = json!({ "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), - "size": p.size(), "source": p.source(), + "size": p.size(), "source": p.source().map(shown_file), "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { @@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value { v } +/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them +/// is `null` and a failed file outside them is named by its file name, in the errors too. +fn shown_report(mut report: Value) -> Value { + let mut hidden: Vec<(String, String)> = vec![]; + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + let shown = shown_file(&file); + if shown != file { + row["file"] = json!(shown); + hidden.push((file, shown)); + } + } + } + if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) { + let shown = shown_file(&folder); + if shown != folder { + report["folder"] = Value::Null; + hidden.push((folder, shown)); + } + } + // A failed file's path holds its folder's: the longer paths are put back first. + hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); + let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name)); + if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) { + report["error"] = json!(e); + } + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) { + row["error"] = json!(e); + } + } + } + report +} + fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone())); + let disabled: Vec = safe_mode::disabled().iter().map(shown_item).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) @@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result { Ok(json!({ "active": safe_mode::active(), "source": safe_mode::source().map(safe_mode::Source::name), - "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>(), - "pendingItem": safe_mode::previous().map(|i| i.to_json()), + "disabled": safe_mode::disabled().iter().map(shown_item).collect::>(), + "pendingItem": safe_mode::previous().map(|i| shown_item(&i)), })) } +/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name +/// by its file name (outside the automation read roots) is found by that name, when no other listed +/// file has it. +fn listed_file(file: String) -> String { + let mut listed: Vec = safe_mode::disabled() + .into_iter() + .chain(safe_mode::previous()) + .filter_map(|i| i.file) + .chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string))) + .collect(); + listed.sort(); + listed.dedup(); + if listed.contains(&file) { + return file; + } + let mut named = listed.into_iter().filter(|f| shown_file(f) == file); + match (named.next(), named.next()) { + (Some(f), None) => f, + _ => file, + } +} + /// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a /// plug-in that stopped a launch while loading may be known by its file alone). It neither loads /// nor unloads anything now, and uninstalls nothing. @@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) } - (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None), (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), }; let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); @@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { // Without a plug-in folder (a session confined to automation roots), or when the list there // can't take the change, the change stays in this process and goes with the next change that saves. let saved = !changed || s.store_disabled_plugins(); - Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>()})) + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::>()})) } fn info(_: &mut Session, p: &Value) -> Result { @@ -1055,7 +1129,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1127,7 +1201,7 @@ pub fn specs() -> Vec { "Safe Mode", [], None, - "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", always, safe_mode_state ), @@ -1136,7 +1210,7 @@ pub fn specs() -> Vec { "Enable on Re-launch", [], None, - "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + "{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", always, set_enabled ), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..f0087595dd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync { fn remove(&self, name: &str) -> std::result::Result<(), String>; /// Where the entries are, as the user knows it (a folder, "browser storage"). fn location(&self) -> String; + /// Is [`RecoveryStore::location`] a folder on this computer (default: no)? + fn in_folder(&self) -> bool { + false + } /// The areas in the store (default: those with entries). fn areas(&self) -> std::result::Result, String> { let names = self.list()?; @@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore { fn location(&self) -> String { self.0.to_string_lossy().into_owned() } + fn in_folder(&self) -> bool { + true + } fn areas(&self) -> std::result::Result, String> { Self::names(&self.0, true) } @@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) { } } +/// The document's file `path` as a reply names it: under automation read roots, only inside them. +fn shown_path(path: Option) -> Option { + path.filter(|p| crate::file_access::check_read(p).is_ok()) +} + +/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation +/// read roots is `null`. +fn shown_location(store: &dyn RecoveryStore) -> Value { + let location = store.location(); + if store.in_folder() && crate::file_access::check_read(&location).is_err() { + return Value::Null; + } + json!(location) +} + fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } @@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result { } forget_clean(s); heartbeat(s); - Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() })) + Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) })) } /// Who an area of the store belongs to. @@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let meta = read_meta(store.as_ref(), f); let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ - "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, + "file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved, "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } @@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result { tidy(store.as_ref(), &area, hold); } } - Ok(json!({ "copies": rows, "location": store.location() })) + Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) })) } /// A gone app's area to act on: its name, the copies to act on, and its lock. @@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) + Ok(super::fileio::merge( + json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }), + links.to_json(), + )) } fn restore(s: &mut Session, p: &Value) -> Result { @@ -1527,7 +1552,7 @@ pub fn specs() -> Vec { "Save Recovery Data", [], None, - "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)", has_store, save ), @@ -1536,7 +1561,7 @@ pub fn specs() -> Vec { "Recovery Data", [], None, - "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash", + "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is", has_store, list ), @@ -1545,7 +1570,7 @@ pub fn specs() -> Vec { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..44a14dbe34 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -233,6 +233,43 @@ impl Session { self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) } + /// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read + /// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in + /// (`file`, also in the run's error line in `console`) are named by their file name. What the + /// script wrote itself (`log`, its output lines) stays as written. + pub fn shown_script_report(&self) -> Option { + let mut report = self.script_report()?.clone(); + if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) { + for path in written.iter_mut() { + if let Some(name) = path.as_str().and_then(hidden_name) { + *path = json!(name); + } + } + } + if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string) + && let Some(name) = hidden_name(&file) + { + // The run's error lines come after every line the script wrote. They are written again + // from the report with the file's name, which also covers a line the console cut inside + // the path. A console that dropped its oldest lines may hold only the last of them. + let before = script::error_lines(&report); + report["file"] = json!(name); + if let Some(message) = report.get("message").and_then(Value::as_str) { + report["message"] = json!(message.replace(&file, &name)); + } + let after = script::error_lines(&report); + if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) { + let kept = (1..=before.len().min(lines.len())) + .rev() + .find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str())))) + .unwrap_or(0); + lines.truncate(lines.len() - kept); + lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l))); + } + } + Some(report) + } + /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result { @@ -248,6 +285,13 @@ impl Session { } } +/// The file name a reply names `path` by when the file is outside the automation read roots; `None` +/// when a reply may name it whole. +fn hidden_name(path: &str) -> Option { + let shown = super::fileio::shown_path(path); + (shown != path).then(|| shown.into_owned()) +} + fn list_installed(s: &mut Session, _: &Value) -> Result { let Some(root) = s.scripts_dir() else { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 0821a11e71..0da75a42ed 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { host.console.borrow_mut().push(line, cut); } +/// The error of the failed run `report` describes, as its console lines give it; `name` is the +/// run's name in those lines ([`console::line_name`]). +fn error_text(name: &str, report: &Value) -> String { + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let message = report["message"].as_str().unwrap_or_default(); + match report["file"].as_str() { + Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"), + _ if line > 0 => format!("{name}:{line}:{column}: {message}"), + _ => format!("{name}: location unavailable: {message}"), + } +} + +/// The console lines of the error of the failed run `report` describes, each as the console keeps +/// it, cut to its limit. +pub(crate) fn error_lines(report: &Value) -> Vec { + let name = console::line_name(report["name"].as_str().unwrap_or_default()); + let mut lines = vec![]; + each_line(&error_text(&name, report), |line, _| lines.push(line)); + lines +} + /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report /// line in the session's console. Adds the run's time, host calls by kind, slowest commands, @@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: } } if report["ok"] == false { - let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let message = report["message"].as_str().unwrap_or_default(); - let text = match report["file"].as_str() { - Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), - _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), - _ => format!("{}: location unavailable: {message}", host.name), - }; - each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); + each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut)); } let timing = clock().is_some(); let ms = millis(host.run_nanos.get()); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..76df1c2194 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed +/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file +/// outside is named by its file name. +#[test] +fn plugin_list_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let base = crate::tests_links::Folder::new("plugin-list-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.listroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap(); + let folder = outside.to_string_lossy().to_string(); + let mut s = session(); + let r = run(&mut s, "plugin.reload", json!({"path": folder})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + let broken = outside.join("broken.wasm").to_string_lossy().to_string(); + let source = outside.join("hidden.wasm").to_string_lossy().to_string(); + let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + + // The person sees the paths. + let person = run(&mut s, "plugin.list", json!({})); + assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}"); + assert_eq!(entry(&person)["source"], json!(source)); + + // The client sees their names. + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let report = &listed["folder"]; + assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}"); + assert_eq!(entry(&listed)["source"], "hidden.wasm"); + let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap(); + assert_eq!(info["source"], "hidden.wasm"); + let outside_text = json!(folder).to_string(); + for reply in [&listed, &info] { + assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}"); + } + + // A read root holding the folder shows it whole. + let wider = AutomationRoots::new(&[&outside], &[]).unwrap(); + let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Under automation read roots, a plug-in turned off for the next launch is listed with a file +/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name. +#[test] +fn disabled_plugins_name_files_outside_the_read_root_by_their_names() { + if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") { + return; + } + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("plugin-disabled-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.disabledroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + let file = outside.join("off.wasm"); + std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap(); + let file = file.to_string_lossy().to_string(); + let mut s = session(); + run(&mut s, "plugin.install", json!({"path": file})); + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + // The client turns it off by the source plugin.list shows. + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone(); + assert_eq!(source, "off.wasm", "{listed}"); + confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap(); + assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path"); + + let outside_text = json!(outside.to_string_lossy()).to_string(); + for cmd in ["plugin.list", "plugin.safeMode"] { + let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap(); + assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}"); + assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}"); + } + let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap(); + assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..1f3867a622 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument { } } +/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and +/// `file.recovery.restore` name a document's file and the recovery folder only inside them. +#[test] +fn recovery_replies_leave_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + use serde_json::Value; + let base = crate::tests_links::Folder::new("recovery-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let store: Arc = Arc::new(FolderStore::new(outside.join("recovery"))); + let location = outside.join("recovery").to_string_lossy().to_string(); + let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string(); + let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + for path in [&kept, &hidden] { + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + } + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap(); + assert_eq!(saved["location"], Value::Null, "{saved}"); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder"); + + let sorted = |v: &Value, key: &str| -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect(); + out.sort(); + out + }; + let both = |first: Value| { + let mut out = vec![first.to_string(), json!(kept).to_string()]; + out.sort(); + out + }; + assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden))); + let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}"); + let outside_text = json!(outside.to_string_lossy()).to_string(); + assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}"); + // A relative recovery folder is checked as the file system reads it; a store that is no folder + // keeps its name. + let mut relative = Session::new(); + relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder"))); + let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], Value::Null, "{l}"); + let (mut memory, _) = session(); + let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], "memory", "{l}"); + + // After a crash, the copies restored. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap(); + assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}"); + let mut opened: Vec> = s.documents().iter().map(|d| d.path.clone()).collect(); + opened.sort(); + let mut files = vec![Some(hidden), Some(kept)]; + files.sort(); + assert_eq!(opened, files, "the documents keep their files"); +} + /// A crash between writing a copy's document and its details restores the new document with /// every image, the one placed since the last copy too (and the first copy likewise). #[test] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index 27f53cef07..48d5c8ed0a 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() { assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]); assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4)); } + +/// Under automation read roots, the error lines of a failed run name the included file it stopped +/// in by its file name also when the console cut a line inside the file's path. +#[test] +fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("script-report-cut-line"); + let inside = base.0.join("in"); + std::fs::create_dir_all(&inside).unwrap(); + // A path longer than a console line, as the report of a failed run names it. + let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string(); + let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")}); + let error = script::error_lines(&report); + assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}"); + let written: Vec = std::iter::once("written by the script".to_string()).chain(error).collect(); + report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2}); + let mut s = Session::new(); + s.script_report = Some(report.clone()); + + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap(); + assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}"); + assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}"); + let outside = json!(base.0.join("out").to_string_lossy()).to_string(); + assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}"); + // The person sees the report as it is. + assert_eq!(s.shown_script_report(), Some(report)); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..ef6a6d20c8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { } let mut inspected = json!({ "scriptRunning": app.session.script_running(), - "scriptReport": app.session.script_report(), + "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 26406a4992..3a84e0b94d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -567,6 +567,50 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect`'s `scriptReport` names a file the person's script wrote outside the read root, and +/// the included file a failed run stopped in (also in its error line), by its file name; the person +/// sees the paths. +#[test] +fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-script-report"); + let (kept, hidden) = (s(base.join("in/kept.svg")), s(base.join("outside/hidden.svg"))); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let source = format!( + "vc.run('document.export', {{path: {kept}, format: 'svg'}}); vc.run('document.export', {{path: {hidden}, format: 'svg'}});", + kept = json!(kept), + hidden = json!(hidden) + ); + let finish = |app: &mut VectorcraftApp, params: Value| { + app.session.execute("script.run", ¶ms).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + }; + finish(&mut app, json!({"source": source, "name": "t.js"})); + let ctx = egui::Context::default(); + let outside = json!(s(base.join("outside"))).to_string(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["written"], json!([kept, hidden]), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["written"], json!([kept, "hidden.svg"]), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(app.session.script_report().unwrap()["written"], json!([kept, hidden]), "the report is not changed"); + + // A run that fails in a file it includes. + std::fs::write(base.join("outside/run.jsx"), "#include \"bad.jsxinc\"\n").unwrap(); + std::fs::write(base.join("outside/bad.jsxinc"), "\nthrow new Error('stopped here');\n").unwrap(); + finish(&mut app, json!({"path": s(base.join("outside/run.jsx"))})); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["file"], json!(s(base.join("outside/bad.jsxinc"))), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["file"], "bad.jsxinc", "{r}"); + assert!(r["result"]["scriptReport"]["console"].to_string().contains("bad.jsxinc:2:"), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..86b5a4af30 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,6 +170,17 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths + outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in + folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source` + (also in `plugin.info`) are named by their file name, in the errors too. A plug-in turned off for the next + launch (`disabled`, and `pendingItem` in `plugin.safeMode`) has a file outside the read roots named by its file + name. `plugin.setEnabled {file}` takes that name, and a plug-in's `source` name, when no other listed file has + it. In `file.recovery.list` and `file.recovery.restore`, a + document's original file in `path` is `null`, and so is the recovery folder in `location` (`file.recovery.save` + too); the restored documents keep their files. In `scriptReport`, a file the run wrote (`written`) and the + included file a failed run stopped in (`file`, also in `message` and the run's error lines in `console`) are named + by their file name; what the script wrote itself (`log` and its output lines) stays as written. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..320a43ccc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -766,6 +766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. + - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).