diff --git a/ROADMAP.md b/ROADMAP.md index 327036c371..3e8cfb9d34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. +- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index f7dd201af2..3808c281f9 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -7,7 +7,7 @@ //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); //! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; +//! `Services::picked_inbox`; //! - Save / Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index b4ca434d90..2de7f3b481 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -17,7 +17,7 @@ use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; use vectorcraft_ui_egui::stylus::StylusFeed; -use vectorcraft_ui_egui::{Services, VectorcraftApp}; +use vectorcraft_ui_egui::{PickedInbox, Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; use crate::locks::WebLocks; @@ -25,6 +25,18 @@ use crate::pencil::SharedPointers; pub(crate) type Inbox = Arc)>>>; +/// The files waiting to open, by where they came from (`Services::inbox`, `picked_inbox` and +/// `page_inbox`). +#[derive(Clone, Default)] +struct OpenInboxes { + /// Dropped on the page. + dropped: Inbox, + /// The open picker's answers. + picked: PickedInbox, + /// Handed over by the page's script (`window.vectorcraft.openFile`). + page: Inbox, +} + /// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and /// whether Shift was held: where dropped files land. type DragPos = Rc>>; @@ -48,7 +60,7 @@ const MAX_RESTARTS: u32 = 3; #[derive(Clone)] struct Host { runner: eframe::WebRunner, - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, drag: DragPos, @@ -77,7 +89,7 @@ pub fn start() { crate::fonts::load_startup_fonts().await; let host = Host { runner: eframe::WebRunner::new(), - inbox: Arc::default(), + inbox: OpenInboxes::default(), place_inbox: Arc::default(), script_inbox: Arc::default(), drag: DragPos::default(), @@ -132,13 +144,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // `window.vectorcraft`: the page's control channel (crate::api). let (control_tx, control_rx) = std::sync::mpsc::channel(); - crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone()); + crate::api::connect(Some(control_tx), host.inbox.page.clone(), ctx.clone()); if let Err(e) = crate::api::install() { log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e)); } @@ -409,7 +421,7 @@ impl eframe::App for WebShell { for f in dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); let target = app.drop_target(&name, pos, shift); - let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + let (inbox, place_inbox, repaint) = (self.host.inbox.dropped.clone(), self.host.place_inbox.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { match f.bytes_async().await { Ok(bytes) => { @@ -432,14 +444,14 @@ impl eframe::App for WebShell { } fn services( - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, ctx: egui::Context, locks: Option, stylus: StylusFeed, ) -> Services { - let open_inbox = inbox.clone(); + let open_inbox = inbox.picked.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); let screen_color = eye_dropper(ctx.clone()); @@ -488,11 +500,12 @@ fn services( let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| { d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) }); - let Some(file) = dialog.pick_file().await else { - return; + // A canceled pick answers `None`. + let answer = match dialog.pick_file().await { + Some(file) => Some((file.file_name(), file.read().await)), + None => None, }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(answer); ctx.request_repaint(); }); })), @@ -500,7 +513,9 @@ fn services( screen_color, // A download that couldn't be started fails the save or export it is for (#1693). download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))), - inbox: Some(inbox), + inbox: Some(inbox.dropped), + picked_inbox: Some(inbox.picked), + page_inbox: Some(inbox.page), recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. print: Some(Box::new(BrowserPrint)), diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index fa0f0dca29..0391b7534b 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -9,6 +9,7 @@ use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; use vectorcraft_ui_egui::dialogs::safe_mode; +use vectorcraft_ui_egui::hang_beacon::{self, BEACON, FrameTime, Stage}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -34,12 +35,16 @@ impl eframe::App for App { // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + let _timed = BEACON.enter_timed(Stage::Logic); file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); + BEACON.enter(Stage::Idle); } /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let _timed = BEACON.enter_timed(Stage::Input); + self.frame_start = Some(std::time::Instant::now()); // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). if let Some(window_frame) = self.window_frame.as_mut() { @@ -60,9 +65,10 @@ impl eframe::App for App { // that no mouse button is down. #[cfg(target_os = "macos")] self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); + BEACON.enter(Stage::Idle); } - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + let _timed = BEACON.enter_timed(Stage::Interface); #[cfg(target_os = "macos")] { let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; @@ -89,8 +95,13 @@ impl eframe::App for App { if !self.app.file_dialog_open() && self.app.take_ime_discard() { discard_marked_text(); } + self.end_frame(ui.ctx(), frame); } fn on_exit(&mut self) { + // The window is closing: saving and teardown are not timed for the hang report, and a report + // of a stall the interface came back from is removed. + let log_dir = super::prefs::log_dir().filter(|_| super::prefs::prefs_enabled()); + super::hang_watch::closing(log_dir.as_deref(), &super::hang_watch::CLOSING, &BEACON); file_access::unconfined(|| save_prefs(&self.app)); // A ready update installs once the process has exited. The macOS exit below skips // dropping the app, so the helper is started here. @@ -102,6 +113,35 @@ impl eframe::App for App { } impl App { + /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a + /// focused, shown window whose windows had input waits for the renderer and the next frame, + /// which the input asks for at once; any other frame leaves the interface thread idle + /// (`hang_watch`). + fn end_frame(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + // A pass egui discards and runs again is not the frame's end: the pass after it is. + if ctx.will_discard() { + return; + } + if let Some(start) = self.frame_start.take() { + let work_ms = start.elapsed().as_secs_f32() * 1000.0; + let previous_total_ms = frame.info().cpu_usage.map(|s| s * 1000.0); + BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len()); + } + // Hide on macOS hides the window at once, before the window reports losing focus. + let main_shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) + && self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false)); + // A floating panel or document window with the focus counts as well, with the commands it + // was sent after its pass, and those its pass sent (`hang_beacon::watch_input`). + let shown = ctx.input(|i| hang_beacon::shown_window(main_shown, &i.raw.viewports)); + let handed_off = BEACON.take_handed_off(); + let input = BEACON.take_input(); + let stage = match shown { + Some(id) => ctx.viewport_for(id, |vp| hang_beacon::after_frame(input && !handed_off.contains(&id), true, &vp.commands)), + None => hang_beacon::after_frame(input, false, &[]), + }; + BEACON.enter(stage); + } + fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] if self.winit_window.as_ref().is_some_and(|w| !w.has_focus() || w.is_visible() == Some(false) || w.is_minimized() == Some(true)) { diff --git a/apps/vectorcraft/src/hang_watch.rs b/apps/vectorcraft/src/hang_watch.rs new file mode 100644 index 0000000000..e5adb77dfa --- /dev/null +++ b/apps/vectorcraft/src/hang_watch.rs @@ -0,0 +1,445 @@ +//! The hang report. A thread reads the interface thread's marks +//! ([`vectorcraft_ui_egui::hang_beacon`]) every [`POLL`]. When the interface thread has made no +//! mark for [`STALL`] while inside a stage of its frame, the thread writes what the app knows to the +//! log (`logs/vectorcraft.log`) and to `logs/hang-report-.txt`: the stage, a clipboard +//! call in progress, the last commands, the last frames' times, the platform, the window system, the +//! graphics processor and the present mode the app asked for, and how long the session has run with +//! how many documents open. The process holds a lock on the file until the interface thread answers +//! again; then the log says after how long and the file is removed. A file nobody holds at a later +//! launch was left by a session that was closed while it did not answer, and a locked one belongs to +//! another instance that is stalled now and is left alone. That launch writes the report to the log, +//! moves the newest abandoned file to `hang-report.1.txt` and removes the others, says so in the +//! status bar, and Help › Copy Troubleshooting Info copies the reports. Where the file system has no +//! file locks, the file is written and taken without one. +//! +//! No native stack is recorded: the marks are what the report has. The report holds command ids and +//! counts, never document contents or file paths. [`STALL`] is VectorCraft's own value. +//! +//! A stall in the renderer is timed only after a frame that had input in any window: input asks for +//! the next frame at once, so a frame that does not start then is late. After a frame without +//! input, the window waiting for input looks the same as one stuck drawing, so that time is not +//! timed. Nor is the time after a frame that minimizes or hides the main window or hands it to the +//! system to move or resize. A window the system stops redrawing while it is hidden can still look +//! like a stall after a frame with input; the report's stage says so. A system file dialog shown in +//! line (on Windows and Linux) and an open menu of the macOS menu bar wait for the user and are not +//! timed. + +use std::fs::File; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant, SystemTime}; + +#[cfg(target_os = "macos")] +use vectorcraft_ui_egui::hang_beacon::Activity; +use vectorcraft_ui_egui::hang_beacon::{BEACON, Beacon, FrameTime, Sample, Stage}; + +use super::logging::timestamp; + +/// How long the interface thread may stay in one stage before the report is written. +pub const STALL: Duration = Duration::from_secs(3); +/// How often the watching thread reads the marks. +pub const POLL: Duration = Duration::from_millis(500); +/// The report of a stall that has not ended, in the log folder: this, the process id, `.txt`. +pub const REPORT_PREFIX: &str = "hang-report-"; +/// At most this many report files are taken when a launch looks for abandoned ones; older abandoned +/// ones are removed. +const MAX_REPORT_FILES: usize = 16; +/// At most this many entries of the log folder are looked at for report files. +const MAX_SCANNED_ENTRIES: usize = 1024; +/// The report the last session left, moved here at launch. +pub const PREVIOUS_FILE: &str = "hang-report.1.txt"; +/// A report file larger than this is not read. +pub const MAX_REPORT_BYTES: u64 = 64 * 1024; +/// Environment values in the report are cut to this many characters. +const MAX_VALUE_CHARS: usize = 64; + +/// What [`Watch::check`] found. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Event { + /// The interface thread has been in the sample's stage, without a mark, for this long. + Stalled(Duration), + /// The interface thread made a mark again after a [`Event::Stalled`], about this long after + /// the stall began. + Answered(Duration), +} + +/// Decides from successive samples when the interface thread has stalled and when it answers +/// again. Time comes from the caller, so tests drive it without waiting. +#[derive(Debug)] +pub struct Watch { + stall: Duration, + /// The step seen in a busy stage and when it was first seen. + seen: Option<(u64, Duration)>, + reported: bool, +} + +impl Watch { + pub fn new(stall: Duration) -> Watch { + Watch { stall, seen: None, reported: false } + } + + /// The marks read at `now` (any monotonic clock that started before the first call). + pub fn check(&mut self, now: Duration, sample: Sample) -> Option { + let same = self.seen.is_some_and(|(step, _)| step == sample.step); + if same && sample.busy() { + let since = self.seen.map_or(now, |(_, since)| since); + let stalled = now.saturating_sub(since); + if !self.reported && stalled >= self.stall { + self.reported = true; + return Some(Event::Stalled(stalled)); + } + return None; + } + let answered = match (self.reported, self.seen) { + (true, Some((_, since))) => Some(Event::Answered(now.saturating_sub(since))), + _ => None, + }; + self.reported = false; + self.seen = sample.busy().then_some((sample.step, now)); + answered + } +} + +/// What the report says about the platform. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Facts { + /// The window system the window opened on: Wayland, X11, AppKit, Win32, other or unknown. + pub window_system: String, + /// `XDG_CURRENT_DESKTOP`. + pub desktop: Option, + /// `XDG_SESSION_TYPE`. + pub session_type: Option, + /// Running in a Flatpak sandbox. + pub flatpak: bool, + /// The graphics processor the window draws with. + pub graphics: Option, + /// The present mode and frame queue the app asked the window surface for. + pub surface: String, +} + +impl Facts { + /// The facts from the environment (`env`, `flatpak_info`: whether `/.flatpak-info` exists) and + /// what the app knows. + pub fn new(env: impl Fn(&str) -> Option, flatpak_info: bool, window_system: &str, graphics: Option, surface: String) -> Facts { + let value = |name: &str| env(name).map(|v| clean(&v)).filter(|v| !v.is_empty()); + Facts { + window_system: window_system.to_string(), + desktop: value("XDG_CURRENT_DESKTOP"), + session_type: value("XDG_SESSION_TYPE"), + flatpak: flatpak_info || env("FLATPAK_ID").is_some_and(|v| !v.is_empty()), + graphics, + surface, + } + } + + /// The facts as report lines. + pub fn lines(&self) -> String { + let or_unknown = |v: &Option| v.clone().unwrap_or_else(|| "unknown".to_string()); + format!( + "VectorCraft {} on {} {}\nWindow system: {}; desktop: {}; session type: {}; Flatpak: {}\nGraphics: {}; {}\n", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + self.window_system, + or_unknown(&self.desktop), + or_unknown(&self.session_type), + if self.flatpak { "yes" } else { "no" }, + or_unknown(&self.graphics), + self.surface, + ) + } +} + +/// `v` with control characters removed, cut to [`MAX_VALUE_CHARS`] characters. +fn clean(v: &str) -> String { + v.chars().filter(|c| !c.is_control()).take(MAX_VALUE_CHARS).collect::().trim().to_string() +} + +/// The marks other than the stage: the last commands, the last frames and the session. +fn activity_lines(beacon: &Beacon, uptime: Duration) -> String { + let commands = match beacon.recent_commands() { + Some(c) if c.is_empty() => "none".to_string(), + Some(c) => c.join(", "), + None => "unavailable (being written)".to_string(), + }; + let (work, total) = match beacon.recent_frames() { + Some(frames) => { + let list = |f: &dyn Fn(&FrameTime) -> Option| { + let times: Vec = frames.iter().filter_map(f).map(|ms| format!("{ms:.1}")).collect(); + if times.is_empty() { "none".to_string() } else { times.join(", ") } + }; + (list(&|t| Some(t.work_ms)), list(&|t| t.previous_total_ms)) + } + None => ("unavailable (being written)".to_string(), "unavailable (being written)".to_string()), + }; + format!( + "Recent commands, oldest first: {commands}\nApp work per frame (ms), oldest first: {work}\nWork and drawing per frame, without waiting for the display (ms), oldest first: {total}\nSession: running for {} s, {} documents open\n", + uptime.as_secs(), + beacon.documents(), + ) +} + +/// The report of a stall that has lasted `stalled` in `sample`'s stage, written at `at`. +pub fn report(at: SystemTime, stalled: Duration, sample: Sample, beacon: &Beacon, uptime: Duration, facts: &Facts) -> String { + let waiting = sample.activity.describe().map(|a| format!("Waiting on: {a}\n")).unwrap_or_default(); + format!( + "VectorCraft hang report, {}\nThe interface thread has not moved on for {:.1} s.\nStage: {}\n{waiting}{}{}No native stack is recorded: the stage and the activity are what the app marks.\n", + timestamp(at), + stalled.as_secs_f64(), + sample.stage.describe(), + activity_lines(beacon, uptime), + facts.lines(), + ) +} + +/// What the watching thread and Help › Copy Troubleshooting Info share. +pub struct Shared { + pub facts: Facts, + pub started: Instant, + /// This session's last report. + pub last: Mutex>, + /// The report the last session left ([`take_previous`]). + pub previous: Option, +} + +/// The text Help › Copy Troubleshooting Info copies. +pub fn troubleshooting(shared: &Shared, beacon: &Beacon, at: SystemTime) -> String { + let last = shared.last.lock().unwrap_or_else(PoisonError::into_inner).clone(); + format!( + "VectorCraft troubleshooting info, {}\n{}{}\nThis session's last hang report:\n{}\nThe hang report the last session left (it was closed while it did not answer):\n{}\n", + timestamp(at), + shared.facts.lines(), + activity_lines(beacon, shared.started.elapsed()), + last.as_deref().unwrap_or("none\n"), + shared.previous.as_deref().unwrap_or("none\n"), + ) +} + +/// The report file of this process in `dir`. +fn report_path(dir: &Path) -> PathBuf { + dir.join(format!("{REPORT_PREFIX}{}.txt", std::process::id())) +} + +/// A report file this process wrote and holds locked until the interface answers again. +#[derive(Debug)] +pub struct Held { + path: PathBuf, + _file: File, +} + +/// Write `text` to this process's report file in `dir` and lock it. Where the file system has no +/// file locks, the file is written unlocked and the log says so. +fn write_report(dir: &Path, text: &str) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let path = report_path(dir); + let mut file = File::create(&path)?; + if let Err(e) = file.lock() { + log::warn!("the hang report file is written without a lock: {e}"); + } + file.write_all(text.as_bytes())?; + Ok(Held { path, _file: file }) +} + +/// Whether a report file whose `try_lock` gave `locked` was abandoned: nobody holds it. A file +/// system without file locks answers [`std::io::ErrorKind::Unsupported`], and its files count as +/// abandoned; any other error leaves the file alone. +fn abandoned(locked: Result<(), std::fs::TryLockError>) -> bool { + match locked { + Ok(()) => true, + Err(std::fs::TryLockError::Error(e)) => e.kind() == std::io::ErrorKind::Unsupported, + Err(std::fs::TryLockError::WouldBlock) => false, + } +} + +/// The report file at `path`, opened and locked, with its length, when it was abandoned +/// ([`abandoned`]) and is not empty (an empty one may be being written). +fn open_abandoned(path: &Path) -> Option<(File, u64)> { + let file = File::options().read(true).write(true).open(path).ok()?; + if !abandoned(file.try_lock()) { + return None; + } + let len = file.metadata().map_or(0, |m| m.len()); + (len > 0).then_some((file, len)) +} + +/// The reports that sessions closed while they did not answer left in `dir`, oldest first; `None` +/// when there are none. The newest file is moved to [`PREVIOUS_FILE`] and the others are removed +/// once read. Of more than [`MAX_REPORT_FILES`], the newest are taken and the older ones removed +/// unread; the first [`MAX_SCANNED_ENTRIES`] entries of `dir` are looked at. A report file another +/// process holds locked, or one still empty, is left alone. A file over [`MAX_REPORT_BYTES`] is +/// taken without being read. A file that can't be moved is removed, so no later launch offers it +/// again: the caller writes the reports to the log. Each file is opened once. +pub fn take_previous(dir: &Path) -> Option { + let mut found: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir) + .ok()? + .take(MAX_SCANNED_ENTRIES) + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix(REPORT_PREFIX)?.strip_suffix(".txt")) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|e| (e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH), e.path())) + .collect(); + found.sort(); + // Newest first: only abandoned reports count toward the limit. + let mut texts = Vec::new(); + for (_, path) in found.into_iter().rev() { + let Some((file, len)) = open_abandoned(&path) else { continue }; + if texts.len() == MAX_REPORT_FILES { + drop(file); + remove_quietly(&path); + continue; + } + let mut bytes = Vec::new(); + let text = if len <= MAX_REPORT_BYTES && (&file).take(MAX_REPORT_BYTES).read_to_end(&mut bytes).is_ok() { + String::from_utf8_lossy(&bytes).into_owned() + } else { + format!("The report was larger than {MAX_REPORT_BYTES} bytes and was not read.\n") + }; + // Closed (and unlocked) before it is moved: Windows moves no open file. + drop(file); + if !texts.is_empty() { + remove_quietly(&path); + } else if let Err(e) = std::fs::rename(&path, dir.join(PREVIOUS_FILE)) { + log::warn!("couldn't move the hang report file to {PREVIOUS_FILE}: {e}"); + remove_quietly(&path); + } + texts.push(text); + } + texts.reverse(); + (!texts.is_empty()).then(|| texts.join("\n")) +} + +/// Remove the report file at `path`; a failure goes to the log. +fn remove_quietly(path: &Path) { + if let Err(e) = std::fs::remove_file(path) { + log::warn!("couldn't remove the hang report file: {e}"); + } +} + +/// Set when the app closes normally ([`closing`]): the watching thread writes no report file after it. +pub static CLOSING: AtomicBool = AtomicBool::new(false); + +/// Remove `held`'s file, closing it first. → the error message; a file already gone is none. +fn remove(held: Held) -> Option { + let Held { path, _file: file } = held; + drop(file); + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Some(e.to_string()), + _ => None, + } +} + +/// Act on what [`Watch::check`] found: write (and hold) or remove this process's report file in +/// `dir`, then log it. The file comes first: logging can wait on what the interface thread waits on. +#[allow(clippy::too_many_arguments)] +pub fn record( + event: Event, + sample: Sample, + beacon: &Beacon, + shared: &Shared, + dir: Option<&Path>, + held: &mut Option, + closing: &AtomicBool, + at: SystemTime, +) { + match event { + Event::Stalled(stalled) => { + let text = report(at, stalled, sample, beacon, shared.started.elapsed(), &shared.facts); + let mut failed = None; + if let Some(dir) = dir.filter(|_| !closing.load(Ordering::Acquire)) { + match write_report(dir, &text) { + Ok(file) => *held = Some(file), + Err(e) => failed = Some(e.to_string()), + } + // The app began to close while the file was written: it is not a report to leave. + if closing.load(Ordering::Acquire) { + failed = held.take().and_then(remove).or(failed); + } + } + *shared.last.lock().unwrap_or_else(PoisonError::into_inner) = Some(text.clone()); + log::warn!("the interface stopped answering:\n{}", text.trim_end()); + if let Some(e) = failed { + log::warn!("couldn't write or remove the hang report file: {e}"); + } + } + Event::Answered(after) => { + let failed = held.take().and_then(remove); + log::warn!("the interface answered again about {:.1} s after it stopped", after.as_secs_f64()); + if let Some(e) = failed { + log::warn!("couldn't remove the hang report file: {e}"); + } + } + } +} + +/// The app is closing normally: mark the interface thread idle on `beacon` (saving and teardown are +/// not timed), remove this process's report file in `dir` and have the watching thread write no +/// more. It does not wait for the watching thread. The file can be removed while that thread holds +/// it open and locked: the standard library opens files with `FILE_SHARE_DELETE` on Windows, and a +/// byte-range lock does not prevent deletion. Where Windows deletes a file only once its last +/// handle closes, the name goes when the process ends. +pub fn closing(dir: Option<&Path>, closing: &AtomicBool, beacon: &Beacon) { + beacon.enter(Stage::Idle); + closing.store(true, Ordering::Release); + if let Some(dir) = dir { + match std::fs::remove_file(report_path(dir)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("couldn't remove the hang report file: {e}"), + _ => {} + } + } +} + +/// The window system `window` opened on, for [`Facts::window_system`]. +pub fn window_system(window: Option<&winit::window::Window>) -> &'static str { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + match window.and_then(|w| w.window_handle().ok()).map(|h| h.as_raw()) { + Some(RawWindowHandle::Wayland(_)) => "Wayland", + Some(RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) => "X11", + Some(RawWindowHandle::AppKit(_)) => "AppKit", + Some(RawWindowHandle::Win32(_)) => "Win32", + Some(_) => "other", + None => "unknown", + } +} + +/// The present mode and frame queue the app asked the window surface for, for [`Facts::surface`]; +/// `None` when the window has no wgpu renderer. +pub fn surface(config: Option<(String, Option)>) -> String { + match config { + Some((mode, Some(frames))) => format!("requested present mode {mode}, frame latency {frames}"), + Some((mode, None)) => format!("requested present mode {mode}"), + None => "no wgpu renderer".to_string(), + } +} + +/// Start the watching thread; the report file goes in `dir` (none: the log only). +pub fn start(dir: Option, shared: Arc) { + let spawned = std::thread::Builder::new().name("hang-watch".into()).spawn(move || { + let mut watch = Watch::new(STALL); + let mut held = None; + loop { + std::thread::sleep(POLL); + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] + let mut sample = BEACON.sample(); + // An open menu of the menu bar waits for the user. + #[cfg(target_os = "macos")] + if super::mac_menu::is_tracking() { + sample.activity = Activity::Menu; + } + if let Some(event) = watch.check(shared.started.elapsed(), sample) { + record(event, sample, &BEACON, &shared, dir.as_deref(), &mut held, &CLOSING, SystemTime::now()); + } + } + }); + if let Err(e) = spawned { + log::warn!("no hang report this session: the watching thread didn't start: {e}"); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/vectorcraft/src/hang_watch/tests.rs b/apps/vectorcraft/src/hang_watch/tests.rs new file mode 100644 index 0000000000..09cf74e403 --- /dev/null +++ b/apps/vectorcraft/src/hang_watch/tests.rs @@ -0,0 +1,270 @@ +use super::*; +use std::time::UNIX_EPOCH; +use vectorcraft_ui_egui::hang_beacon::{Activity, Stage}; + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn sample(step: u64, stage: Stage) -> Sample { + Sample { step, stage, activity: Activity::None } +} + +fn facts() -> Facts { + let env = |name: &str| match name { + "XDG_CURRENT_DESKTOP" => Some("KDE".to_string()), + "XDG_SESSION_TYPE" => Some("wayland\u{7}".to_string()), + _ => None, + }; + Facts::new(env, true, "Wayland", Some("Test GPU (Vulkan, DiscreteGpu)".to_string()), surface(Some(("AutoVsync".to_string(), Some(1))))) +} + +fn shared(previous: Option) -> Shared { + Shared { facts: facts(), started: Instant::now(), last: Mutex::new(None), previous } +} + +#[test] +fn a_stage_held_for_the_stall_time_is_reported_once_and_its_end_after_it() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(2_999), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(3_000), sample(5, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + assert_eq!(w.check(ms(9_000), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(9_500), sample(6, Stage::Idle)), Some(Event::Answered(ms(9_500)))); + assert_eq!(w.check(ms(20_000), sample(6, Stage::Idle)), None); +} + +#[test] +fn frames_that_keep_moving_and_an_idle_window_are_never_reported() { + let mut w = Watch::new(STALL); + // A long animation: the step changes at every read. + for n in 0..100 { + assert_eq!(w.check(ms(n * 500), sample(n, Stage::Drawing)), None); + } + // A window waiting for input for a minute. + for n in 0..120 { + assert_eq!(w.check(ms(50_000 + n * 500), sample(1_000, Stage::Idle)), None); + } +} + +#[test] +fn a_file_dialog_shown_in_line_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let dialog = Sample { step: 7, stage: Stage::Interface, activity: Activity::Dialog }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), dialog), None); + } + // Back from the dialog, the command goes on and is timed again. + assert_eq!(w.check(ms(30_000), sample(8, Stage::Interface)), None); + assert_eq!(w.check(ms(33_000), sample(8, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); +} + +#[test] +fn an_open_menu_bar_menu_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let menu = Sample { step: 3, stage: Stage::Drawing, activity: Activity::Menu }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), menu), None); + } +} + +#[test] +fn a_new_busy_step_starts_the_time_again() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(1, Stage::Logic)), None); + assert_eq!(w.check(ms(2_500), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_000), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_500), sample(2, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + // A mark inside the stall (a clipboard read ending) is an answer. + assert_eq!(w.check(ms(6_000), sample(3, Stage::Interface)), Some(Event::Answered(ms(3_500)))); +} + +#[test] +fn the_report_names_the_stage_the_activity_the_commands_and_the_platform() { + let beacon = Beacon::new(); + beacon.command("edit.copy"); + beacon.command("edit.paste"); + beacon.frame(FrameTime { work_ms: 1.24, previous_total_ms: Some(3.5) }, 2); + let s = Sample { step: 9, stage: Stage::Interface, activity: Activity::ClipboardRead }; + let text = report(UNIX_EPOCH + Duration::from_secs(1_794_385_145), ms(3_240), s, &beacon, Duration::from_secs(754), &facts()); + for want in [ + "VectorCraft hang report, 2026-11-11T08:19:05.000Z", + "has not moved on for 3.2 s", + "Stage: running the interface", + "Waiting on: reading the system clipboard", + "Recent commands, oldest first: edit.copy, edit.paste", + "App work per frame (ms), oldest first: 1.2", + "without waiting for the display (ms), oldest first: 3.5", + "Session: running for 754 s, 2 documents open", + "Window system: Wayland; desktop: KDE; session type: wayland; Flatpak: yes", + "Graphics: Test GPU (Vulkan, DiscreteGpu); requested present mode AutoVsync, frame latency 1", + ] { + assert!(text.contains(want), "{want:?} missing from:\n{text}"); + } +} + +#[test] +fn environment_values_lose_control_characters_and_are_cut_short() { + let long = "x".repeat(500); + let env = |name: &str| (name == "XDG_CURRENT_DESKTOP").then(|| format!("A\nB{long}")); + let f = Facts::new(env, false, "X11", None, String::new()); + assert_eq!(f.desktop.as_deref().map(str::len), Some(MAX_VALUE_CHARS)); + assert!(f.desktop.as_deref().is_some_and(|d| d.starts_with("ABx"))); + assert!(!f.flatpak); + assert!(f.lines().contains("Graphics: unknown")); +} + +#[test] +fn a_stall_writes_the_report_file_and_its_end_removes_it() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let s = sample(4, Stage::Drawing); + let (mut held, open) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + let path = report_path(dir.path()); + // Windows refuses reads through another handle while the file is locked: the length is read. + let last = shared.last.lock().unwrap().clone().unwrap_or_default(); + assert!(last.contains("Stage: after a frame with input went to the renderer")); + assert_eq!(std::fs::metadata(&path).unwrap().len(), last.len() as u64); + // While the stall lasts, a launch of another instance leaves the file alone. + assert_eq!(take_previous(dir.path()), None); + assert!(path.exists()); + record(Event::Answered(ms(4_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + assert!(!path.exists()); + // This session's report stays for Copy Troubleshooting Info. + assert!(shared.last.lock().unwrap().is_some()); +} + +#[test] +fn closing_normally_removes_the_report_and_writes_no_more() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let (mut held, flag) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), sample(4, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + let path = report_path(dir.path()); + assert!(path.exists()); + // The interface answered after a frame with input, and the app quit before the watching thread + // looked again: saving and teardown are not timed. + beacon.enter(Stage::Drawing); + closing(Some(dir.path()), &flag, &beacon); + assert_eq!(beacon.sample().stage, Stage::Idle); + assert!(!path.exists()); + assert_eq!(take_previous(dir.path()), None); + // The watching thread then sees the answer. + record(Event::Answered(ms(3_500)), sample(5, Stage::Idle), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(held.is_none()); + // A stall the watching thread finds while the app closes goes to the log only. + record(Event::Stalled(ms(3_000)), sample(9, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(!path.exists()); + assert!(held.is_none()); +} + +#[test] +fn the_next_launch_takes_a_left_report_once_and_keeps_a_copy() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(take_previous(dir.path()), None); + let left = dir.path().join(format!("{REPORT_PREFIX}4242.txt")); + std::fs::write(&left, "VectorCraft hang report, then\n").unwrap(); + // Other files in the log folder are not reports. + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}notes.txt")), "x").unwrap(); + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}17.txt")), "").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("VectorCraft hang report, then\n")); + assert!(!left.exists()); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "VectorCraft hang report, then\n"); + assert_eq!(take_previous(dir.path()), None); + // An empty file may be one being written: it stays. + assert!(dir.path().join(format!("{REPORT_PREFIX}17.txt")).exists()); +} + +#[test] +fn of_several_left_reports_the_newest_is_kept_and_all_are_offered_oldest_first() { + let dir = tempfile::tempdir().unwrap(); + for (pid, text, secs) in [(9, "newer\n", 2_000), (10, "older\n", 1_000)] { + let path = dir.path().join(format!("{REPORT_PREFIX}{pid}.txt")); + std::fs::write(&path, text).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("older\n\nnewer\n")); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "newer\n"); +} + +#[test] +fn a_report_that_cannot_be_moved_is_offered_once() { + let dir = tempfile::tempdir().unwrap(); + // Something that is not a file is in the way. + std::fs::create_dir_all(dir.path().join(PREVIOUS_FILE).join("x")).unwrap(); + let left = dir.path().join(format!("{REPORT_PREFIX}5.txt")); + std::fs::write(&left, "kept\n").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("kept\n")); + assert!(!left.exists()); + assert_eq!(take_previous(dir.path()), None); +} + +#[test] +fn reports_beyond_the_newest_are_removed_not_left_for_a_later_launch() { + let dir = tempfile::tempdir().unwrap(); + let count = MAX_REPORT_FILES + 3; + for n in 0..count { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, format!("report {n}\n")).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(1_000 + n as u64)).unwrap(); + } + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("report 3\n"), "{text}"); + assert!(text.ends_with(&format!("report {}\n", count - 1)), "{text}"); + assert!(!text.contains("report 2\n")); + assert_eq!(take_previous(dir.path()), None); + let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from(PREVIOUS_FILE)]); +} + +#[test] +fn newer_empty_files_do_not_push_out_an_abandoned_report() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join(format!("{REPORT_PREFIX}1.txt")); + std::fs::write(&old, "old\n").unwrap(); + std::fs::File::options().write(true).open(&old).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(1_000)).unwrap(); + for n in 0..MAX_REPORT_FILES + 1 { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, "").unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(2_000)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("old\n")); +} + +#[test] +fn a_file_system_without_locks_does_not_hide_a_report() { + use std::io::{Error, ErrorKind}; + assert!(abandoned(Ok(()))); + assert!(!abandoned(Err(std::fs::TryLockError::WouldBlock))); + assert!(abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::Unsupported))))); + // Any other error may hide a live session's lock: the file is left alone. + assert!(!abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::PermissionDenied))))); +} + +#[test] +fn an_oversized_report_file_is_not_read() { + let dir = tempfile::tempdir().unwrap(); + let big = vec![b'x'; usize::try_from(MAX_REPORT_BYTES).unwrap() + 1]; + let left = dir.path().join(format!("{REPORT_PREFIX}7.txt")); + std::fs::write(&left, big).unwrap(); + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("The report was larger than")); + assert!(!left.exists()); +} + +#[test] +fn troubleshooting_info_holds_both_sessions_reports() { + let shared = shared(Some("VectorCraft hang report, last session\n".to_string())); + *shared.last.lock().unwrap() = Some("VectorCraft hang report, this session\n".to_string()); + let beacon = Beacon::new(); + let text = troubleshooting(&shared, &beacon, UNIX_EPOCH); + assert!(text.starts_with("VectorCraft troubleshooting info, 1970-01-01T00:00:00.000Z\n")); + assert!(text.contains("This session's last hang report:\nVectorCraft hang report, this session\n")); + assert!(text.contains("(it was closed while it did not answer):\nVectorCraft hang report, last session\n")); + assert!(text.contains("Recent commands, oldest first: none")); +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde7..50f1792eb8 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -120,7 +120,6 @@ fn make_folder(request: &PickRequest) { #[cfg(not(target_os = "macos"))] #[cfg_attr(windows, allow(unused_variables))] fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { - make_folder(request); #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] if let PickRequest::Save(pick) = request && let Some(answer) = portal_save::pick(pick, parent.as_deref()) @@ -139,7 +138,30 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec { - show_dialog(file_dialog(&request, parent), &request, parent) + let beacon = &vectorcraft_ui_egui::hang_beacon::BEACON; + marked_dialog(beacon, || make_folder(&request), || show_dialog(file_dialog(&request, parent), &request, parent)) +} + +/// Run `prepare` (making the dialog's folder), then `show`, marked on `beacon` as a dialog: the +/// dialog waits for the user and is not a stall for the hang report (`hang_watch`), while `prepare` +/// is still timed. +#[cfg(not(target_os = "macos"))] +fn marked_dialog(beacon: &vectorcraft_ui_egui::hang_beacon::Beacon, prepare: impl FnOnce(), show: impl FnOnce() -> T) -> T { + prepare(); + let _mark = beacon.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); + show() +} + +#[cfg(all(test, not(target_os = "macos")))] +#[test] +fn making_a_dialogs_folder_is_timed_and_the_dialog_is_not() { + use vectorcraft_ui_egui::hang_beacon::{Activity, Beacon}; + let beacon = Beacon::new(); + let mut preparing = None; + let showing = marked_dialog(&beacon, || preparing = Some(beacon.sample().activity), || beacon.sample().activity); + assert_eq!(preparing, Some(Activity::None), "making the folder is timed"); + assert_eq!(showing, Activity::Dialog, "the dialog is not"); + assert_eq!(beacon.sample().activity, Activity::None, "the mark ends with the dialog"); } /// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an @@ -239,6 +261,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti std::thread::Builder::new() .name("file-dialog".into()) .spawn(move || { + make_folder(&request); // The app gone meanwhile has no use for the answer. let _ = tx.send(show_dialog(dialog, &request, &parent)); ui.request_repaint(); @@ -363,7 +386,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) })), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard(clip)), + // Its calls are marked for the hang report (`hang_watch`). + system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard // owner that never answers holds a read for up to 4 s. Windows only asks which formats the // clipboard holds and macOS asks the pasteboard server, so they check in line. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1cc..33e03b1f72 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -30,6 +30,7 @@ mod control_server; mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; +mod hang_watch; #[cfg(target_os = "linux")] mod linux_stylus; mod logging; @@ -123,6 +124,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). + frame_start: Option, } /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. @@ -293,6 +296,11 @@ fn main() -> std::process::ExitCode { None => logger.no_file(), } } + // The report a session left when it was closed while it did not answer (`hang_watch`). + let previous_hang = log_dir().filter(|_| prefs_enabled()).and_then(|dir| hang_watch::take_previous(&dir)); + if let Some(report) = &previous_hang { + log::warn!("the last session was closed while it did not answer; its report:\n{}", report.trim_end()); + } // The roots confine the control channel: a bad one keeps the app from starting rather than // leaving agents unconfined. Without a control port there is nothing to confine. let roots = if control_port.is_none() { @@ -437,6 +445,9 @@ fn main() -> std::process::ExitCode { open_documents::install(); #[cfg(feature = "wgpu")] let created = startup.clone(); + // For the hang report (`hang_watch`). + #[cfg(feature = "wgpu")] + let requested_surface = options.wgpu_options.surface; // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash. let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| { #[cfg(target_os = "linux")] @@ -537,6 +548,36 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + // The hang report and Help › Copy Troubleshooting Info (`hang_watch`). + // The surface settings the options asked for; the driver resolves the present mode. + #[cfg(feature = "wgpu")] + let surface = cc + .wgpu_render_state + .as_ref() + .map(|_| (format!("{:?}", requested_surface.present_mode), requested_surface.desired_maximum_frame_latency)); + #[cfg(not(feature = "wgpu"))] + let surface = None; + let hang = std::sync::Arc::new(hang_watch::Shared { + facts: hang_watch::Facts::new( + |name| std::env::var(name).ok(), + std::path::Path::new("/.flatpak-info").exists(), + hang_watch::window_system(winit_window.as_deref()), + app.graphics_adapter.clone(), + hang_watch::surface(surface), + ), + started: std::time::Instant::now(), + last: std::sync::Mutex::new(None), + previous: previous_hang.clone(), + }); + vectorcraft_ui_egui::hang_beacon::watch_input(&vectorcraft_ui_egui::hang_beacon::BEACON, &cc.egui_ctx); + hang_watch::start(log_dir().filter(|_| prefs_enabled()), hang.clone()); + app.services.troubleshooting = Some(Box::new(move || { + hang_watch::troubleshooting(&hang, &vectorcraft_ui_egui::hang_beacon::BEACON, std::time::SystemTime::now()) + })); + // A message about the graphics processor, or a stopped plug-in, comes first. + if previous_hang.is_some() && app.ui.status.is_empty() { + vectorcraft_ui_egui::troubleshooting::last_session_stopped(&mut app); + } app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; app.desktop_frame = !custom_titlebar; @@ -583,6 +624,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + frame_start: None, })) }), ) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 525e9b6734..8125d02c93 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -281,6 +281,10 @@ pub struct DxfOptions { pub scale_lineweights: bool, pub colors: ColorDepth, pub raster: RasterFormat, + /// Placed images are written as IMAGE entities with their files (else left out, undecoded). + pub include_images: bool, + /// Filled areas get a solid HATCH after their outlines (else the outlines alone). + pub include_hatches: bool, pub preserve: Preserve, /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have @@ -303,6 +307,8 @@ impl Default for DxfOptions { scale_lineweights: false, colors: ColorDepth::default(), raster: RasterFormat::default(), + include_images: true, + include_hatches: true, preserve: Preserve::default(), alter_paths: false, outline_text: false, @@ -322,7 +328,8 @@ pub enum DxfPreset { /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), - /// and placed images as PNG. + /// and placed images and fill hatches left out (fills are their outlines), with PNG as the + /// format images take when they are put back in. Cnc, } @@ -360,6 +367,8 @@ impl DxfPreset { scale_lineweights: false, colors: ColorDepth::Aci256, raster: RasterFormat::Png, + include_images: false, + include_hatches: false, preserve: Preserve::Editability, alter_paths: false, outline_text: true, @@ -377,6 +386,8 @@ impl DxfPreset { && o.scale_lineweights == c.scale_lineweights && o.colors == c.colors && o.raster == c.raster + && o.include_images == c.include_images + && o.include_hatches == c.include_hatches && o.preserve == c.preserve && o.alter_paths == c.alter_paths && o.outline_text == c.outline_text; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 3faf94423f..7c5d8b6755 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,5 +1,6 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint -//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes +//! order. Fills are their outlines then solid hatches (the outlines alone in R12 or with +//! hatches left out), placed images are linked image files unless left out, strokes //! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or //! filled outlines where the look needs them. @@ -28,6 +29,8 @@ const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are wr const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque"; const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines"; const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out"; +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; const WIDE_LINES: &str = "lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width"; const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; @@ -339,6 +342,32 @@ fn subpaths(bp: &BezPath) -> Vec { out } +/// `bp` with each open subpath that can enclose an area closed, as a fill closes it: two or more +/// segments, or one curve. A single straight segment stays open. +fn closed_fill(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let (mut segs, mut curved) = (0usize, false); + let close = |out: &mut BezPath, segs: usize, curved: bool| { + if segs >= 2 || (segs == 1 && curved) { + out.close_path(); + } + }; + for el in bp.elements() { + match *el { + PathEl::MoveTo(_) => { + close(&mut out, segs, curved); + (segs, curved) = (0, false); + } + PathEl::ClosePath => (segs, curved) = (0, false), + PathEl::LineTo(_) => segs += 1, + PathEl::QuadTo(..) | PathEl::CurveTo(..) => (segs, curved) = (segs + 1, true), + } + out.push(*el); + } + close(&mut out, segs, curved); + out +} + /// The most pixels of an image written (it is held decoded, as RGBA and encoded at once). const MAX_IMAGE_PIXELS: u64 = 64 << 20; @@ -878,13 +907,13 @@ impl<'a> Scene<'a> { Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } - /// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser - /// and cutter software reads the outlines and skips hatches. `clean`: the path has no + /// A filled area as its outlines followed by a solid hatch (R12, or with hatches left out: the + /// outlines alone). Laser and cutter software reads the outlines and skips hatches. `clean`: the path has no /// overlaps, so the hatch's even-odd rule reads it as it is. fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { if !self.o.version.handles() { self.warn(NO_FILLS); - self.lines(bp, style, 0.0); + self.lines(&closed_fill(bp), style, 0.0); return; } let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1; @@ -893,11 +922,17 @@ impl<'a> Scene<'a> { .flatten() .map(|p| p.to_bezpath()); let outline = normalized.as_ref().unwrap_or(bp); + // A fill with no loop of three points or more encloses no area: no outline and no hatch. let loops: Vec> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect(); if loops.is_empty() { return; } - self.lines(outline, style, 0.0); + // The outlines close as the fill does, so an open path's fill keeps its closing edge. + self.lines(&closed_fill(outline), style, 0.0); + if !self.o.include_hatches { + self.warn(HATCHES_OFF); + return; + } let h = self.handle(); let layer = self.layer.clone(); self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch"); @@ -1285,6 +1320,10 @@ impl<'a> Scene<'a> { self.warn(NO_IMAGES); return; } + if !self.o.include_images { + self.warn(IMAGES_OFF); + return; + } let Some(i) = self.image_def(im) else { return }; let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 90e4cbc2c6..9d2a519e1d 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -843,10 +843,151 @@ fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_stro assert!(out.images.is_empty()); assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_images: true, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_hatches: true, ..o.clone() }), DxfPreset::Custom); assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); } +/// A 4 × 2 px PNG placed at (10, 20) under key `raster-1`, and before it in paint order +/// `fill`'s rectangle. +fn image_and_fill_doc(fill: Paint) -> Document { + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = doc_with(|d| { + let im = ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ + path(d, shapes::rectangle(Rect::new(50.0, 10.0, 150.0, 60.0)), fill, Paint::None, 0.0), + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))), + ] + }); + d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); + d +} + +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; + +#[test] +fn fills_without_hatches_keep_their_outlines() { + let d = shapes_doc(); + let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); + assert_eq!(out.warnings, [HATCHES_OFF]); + let e = entities(&out); + assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); + // Hatches stay by default, with no note. + let on = export(&d, &opts(&d)).unwrap(); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + // The outlines are the ones written beside hatches, also for overlapping subpaths, which are + // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. + let compound = doc_with(|d| { + let mut bp = shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)).to_bezpath(); + bp.extend(shapes::rectangle(Rect::new(40.0, 40.0, 90.0, 90.0)).to_bezpath().elements().iter().copied()); + let p = PathData::from_bezpath(&bp); + vec![ + path(d, p, Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::rectangle(Rect::new(10.0, 70.0, 90.0, 70.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::line(Point::new(100.0, 10.0), Point::new(150.0, 60.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + ] + }); + let without_hatches = |o: &DxfOutput| { + let mut e = entities(o); + e.retain(|x| kind(x) != "HATCH"); + e.into_iter().map(|x| x.into_iter().filter(|p| p.0 != 5).collect::()).collect::>() + }; + let (c_on, c_off) = + (export(&compound, &opts(&compound)).unwrap(), export(&compound, &DxfOptions { include_hatches: false, ..opts(&compound) }).unwrap()); + assert_eq!(kinds(&c_on), ["LWPOLYLINE", "HATCH"], "one merged outline"); + assert_eq!(without_hatches(&c_off), without_hatches(&c_on)); + assert_eq!(without_hatches(&out), without_hatches(&on)); + // Strokes outlined for their look are areas too: their outlines stay. + let altered = + export(&d, &DxfOptions { include_hatches: false, alter_paths: true, preserve: Preserve::Editability, version: DxfVersion::R14, ..opts(&d) }) + .unwrap(); + assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); + // Nothing filled: no note. + let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + // R12 has no hatches anyway: the same outlines, and its own note alone. + let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); + let (off, on) = (r12(false), r12(true)); + assert_eq!(off.bytes, on.bytes); + assert_eq!(off.warnings, on.warnings); + assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); +} + +#[test] +fn an_open_path_s_fill_outline_keeps_its_closing_edge() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((90.0, 10.0)); + bp.line_to((50.0, 80.0)); + let open = PathData::from_bezpath(&bp); + let d = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0)]); + let closed = |e: &Pairs| get(e, 70).and_then(|v| v.trim().parse::().ok()).is_some_and(|v| v & 1 == 1); + for include_hatches in [false, true] { + let out = export(&d, &DxfOptions { include_hatches, ..opts(&d) }).unwrap(); + let e: Vec = entities(&out).into_iter().filter(|x| kind(x) == "LWPOLYLINE").collect(); + assert_eq!(e.len(), 2, "the fill's outline and the stroke: {:?}", kinds(&out)); + assert!(closed(&e[0]), "the fill's outline closes as the fill does (hatches {include_hatches})"); + assert!(!closed(&e[1]), "the stroke stays open (hatches {include_hatches})"); + } + // R12 writes the outline alone, closed too. + let fill_only = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = export(&fill_only, &DxfOptions { version: DxfVersion::R12, ..opts(&fill_only) }).unwrap(); + let e = entities(&r12); + assert_eq!(kind(&e[0]), "POLYLINE", "{:?}", kinds(&r12)); + assert!(closed(&e[0]), "R12's outline closes as the fill does"); +} + +#[test] +fn images_can_be_left_out_with_their_files() { + let d = image_and_fill_doc(Paint::None); + let out = export(&d, &DxfOptions { include_images: false, ..opts(&d) }).unwrap(); + assert!(entities(&out).is_empty() && out.images.is_empty(), "{:?}", kinds(&out)); + let p = pairs(&out.bytes); + assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); + assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); + assert_eq!(out.warnings, [IMAGES_OFF]); + // An image that can't be decoded is not read when images are left out. + let mut broken = d.clone(); + broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); + assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); + // R12 has no images anyway: its own note alone. + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); +} + +#[test] +fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both() { + let d = image_and_fill_doc(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let run = |o: DxfOptions| export(&d, &DxfOptions { region: d.artboards[0].rect, ..o }).unwrap(); + let both = run(DxfOptions::default()); + assert_eq!(kinds(&both), ["LWPOLYLINE", "HATCH", "IMAGE"]); + assert_eq!(both.images.len(), 1); + let no_images = run(DxfOptions { include_images: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_images), no_images.images.len()), (vec!["LWPOLYLINE".to_string(), "HATCH".into()], 0)); + let no_hatches = run(DxfOptions { include_hatches: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_hatches), no_hatches.images.len()), (vec!["LWPOLYLINE".to_string(), "IMAGE".into()], 1)); + // CNC / CAM: the cutting path alone, with no image file and both notes. + let cnc = run(DxfPreset::Cnc.options()); + assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); + assert!(cnc.images.is_empty()); + assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + let custom = DxfPreset::Custom.options(); + assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index e96760d5c5..5b5fd3653a 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, OnceLock}; use serde::{Deserialize, Serialize}; -use crate::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; +use crate::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; /// A library: its name, its ungrouped swatches and its colour groups. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -72,6 +72,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[ lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients), lib!("sky-gradients", "Sky Gradients", sky_gradients), lib!("neutral-gradients", "Neutral Gradients", neutral_gradients), + lib!("organic-fades", "Organic Fades", organic_fades), lib!("duotone-gradients", "Duotone Gradients", duotone_gradients), lib!("radial-glows", "Radial Glows", radial_glows), ]; @@ -179,6 +180,14 @@ pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32 Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false } } +/// A gradient swatch mixed by `method` through `stops` of (offset, color, opacity, midpoint). +fn gradient_with(name: &str, kind: GradientKind, method: GradientMethod, stops: &[(f32, Color, f32, f32)]) -> Swatch { + let stops = + stops.iter().map(|&(offset, color, opacity, midpoint)| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }).collect(); + let gradient = Gradient { method, ..Gradient::new(kind, stops) }; + Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(gradient))), global: false, spot: false } +} + /// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1), /// named "`name` 1"… fn ramp(name: &str, n: usize, (l0, l1): (f64, f64), c: impl Fn(f64) -> f64, h: impl Fn(f64) -> f64) -> SwatchGroup { @@ -387,6 +396,141 @@ fn radial_glows() -> (Vec, Vec) { (s, vec![]) } +/// A stop of [`organic_fades`]: offset, OKLCH `(l, c, h°)`, opacity and midpoint. +type FadeStop = (f32, (f64, f64, f64), f32, f32); + +/// Soft fades through a few unevenly spaced stops with off-center midpoints, mixed perceptually, +/// that thin out to transparent: at the end of the linear ones and at the edge of the radial ones. +fn organic_fades() -> (Vec, Vec) { + use GradientKind::{Linear, Radial}; + let fades: [(&str, GradientKind, &[FadeStop]); 15] = [ + ( + "Mist", + Linear, + &[ + (0.0, (0.94, 0.012, 240.0), 1.0, 0.38), + (0.35, (0.86, 0.03, 235.0), 0.85, 0.62), + (0.7, (0.8, 0.035, 230.0), 0.4, 0.45), + (1.0, (0.78, 0.035, 230.0), 0.0, 0.5), + ], + ), + ( + "Petal", + Linear, + &[ + (0.0, (0.82, 0.09, 350.0), 1.0, 0.42), + (0.28, (0.88, 0.07, 10.0), 0.9, 0.66), + (0.64, (0.93, 0.04, 30.0), 0.45, 0.35), + (1.0, (0.95, 0.03, 40.0), 0.0, 0.5), + ], + ), + ("Fern", Linear, &[(0.0, (0.52, 0.1, 145.0), 1.0, 0.6), (0.4, (0.68, 0.11, 135.0), 0.75, 0.4), (1.0, (0.85, 0.08, 120.0), 0.0, 0.5)]), + ( + "Smoke", + Linear, + &[ + (0.0, (0.35, 0.01, 260.0), 1.0, 0.3), + (0.22, (0.48, 0.012, 255.0), 0.8, 0.58), + (0.58, (0.62, 0.01, 250.0), 0.35, 0.7), + (1.0, (0.72, 0.008, 250.0), 0.0, 0.5), + ], + ), + ( + "Honey", + Linear, + &[ + (0.0, (0.72, 0.15, 70.0), 1.0, 0.55), + (0.3, (0.8, 0.14, 80.0), 0.9, 0.35), + (0.75, (0.9, 0.09, 90.0), 0.3, 0.6), + (1.0, (0.95, 0.05, 95.0), 0.0, 0.5), + ], + ), + ("Seafoam", Linear, &[(0.0, (0.7, 0.09, 175.0), 1.0, 0.45), (0.45, (0.84, 0.07, 170.0), 0.6, 0.65), (1.0, (0.95, 0.03, 165.0), 0.0, 0.5)]), + ( + "Lavender", + Linear, + &[ + (0.0, (0.62, 0.12, 300.0), 1.0, 0.36), + (0.3, (0.72, 0.1, 295.0), 0.85, 0.55), + (0.55, (0.82, 0.07, 290.0), 0.5, 0.68), + (0.82, (0.9, 0.04, 285.0), 0.15, 0.4), + (1.0, (0.93, 0.03, 285.0), 0.0, 0.5), + ], + ), + ("Driftwood", Linear, &[(0.0, (0.55, 0.04, 60.0), 1.0, 0.64), (0.38, (0.68, 0.035, 70.0), 0.7, 0.42), (1.0, (0.84, 0.025, 80.0), 0.0, 0.5)]), + ( + "Ink Wash", + Linear, + &[ + (0.0, (0.25, 0.05, 265.0), 1.0, 0.28), + (0.18, (0.35, 0.06, 262.0), 0.9, 0.6), + (0.5, (0.5, 0.05, 258.0), 0.45, 0.72), + (1.0, (0.65, 0.04, 255.0), 0.0, 0.5), + ], + ), + ( + "Tide", + Linear, + &[ + (0.0, (0.45, 0.1, 230.0), 1.0, 0.4), + (0.26, (0.6, 0.1, 215.0), 0.8, 0.62), + (0.72, (0.76, 0.08, 200.0), 0.35, 0.44), + (1.0, (0.88, 0.05, 195.0), 0.0, 0.5), + ], + ), + ( + "Bloom", + Radial, + &[ + (0.0, (0.92, 0.05, 40.0), 1.0, 0.4), + (0.22, (0.8, 0.12, 20.0), 0.95, 0.6), + (0.6, (0.7, 0.14, 355.0), 0.4, 0.35), + (1.0, (0.66, 0.13, 345.0), 0.0, 0.5), + ], + ), + ("Dew", Radial, &[(0.0, (0.97, 0.01, 200.0), 1.0, 0.3), (0.3, (0.88, 0.05, 195.0), 0.7, 0.58), (1.0, (0.8, 0.06, 190.0), 0.0, 0.5)]), + ( + "Pollen", + Radial, + &[ + (0.0, (0.96, 0.08, 100.0), 1.0, 0.45), + (0.18, (0.9, 0.15, 95.0), 1.0, 0.65), + (0.55, (0.82, 0.16, 85.0), 0.5, 0.4), + (1.0, (0.76, 0.15, 75.0), 0.0, 0.5), + ], + ), + // A ring: clear at the center, then a band that fades out to the edge. + ( + "Halo", + Radial, + &[ + (0.0, (0.98, 0.005, 90.0), 0.0, 0.6), + (0.42, (0.95, 0.02, 85.0), 0.0, 0.35), + (0.62, (0.92, 0.04, 80.0), 1.0, 0.55), + (1.0, (0.88, 0.05, 75.0), 0.0, 0.5), + ], + ), + ( + "Lichen", + Radial, + &[ + (0.0, (0.8, 0.07, 120.0), 1.0, 0.58), + (0.35, (0.7, 0.08, 130.0), 0.75, 0.4), + (0.74, (0.6, 0.07, 140.0), 0.25, 0.62), + (1.0, (0.55, 0.06, 145.0), 0.0, 0.5), + ], + ), + ]; + let s = fades + .iter() + .map(|&(name, kind, stops)| { + let stops: Vec<(f32, Color, f32, f32)> = stops.iter().map(|&(at, (l, c, h), opacity, mid)| (at, oklch(l, c, h), opacity, mid)).collect(); + gradient_with(name, kind, GradientMethod::Perceptual, &stops) + }) + .collect(); + (s, vec![]) +} + /// The sequential scales of [`perceptual`]: name and anchors of rising lightness. const SEQUENTIAL: [(&str, Anchors); 5] = [ ("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]), @@ -532,6 +676,45 @@ mod tests { let st = &g.gradient.stops; assert!(st.len() >= 2 && st.windows(2).all(|w| w[0].offset <= w[1].offset), "{}", s.name); assert!(st.iter().all(|s| (0.0..=1.0).contains(&s.offset) && (0.0..=1.0).contains(&s.opacity))); + assert!(st.iter().all(|s| (0.01..=0.99).contains(&s.midpoint)), "{}: a midpoint out of range", s.name); + } + } + } + } + + #[test] + fn organic_fades_are_perceptual_multi_stop_fades_to_transparent() { + let lib = builtin_library("organic-fades").unwrap(); + assert_eq!(GRADIENT_LIBRARIES.iter().map(|b| b.name).position(|n| n == "Organic Fades"), Some(3), "after Neutral Gradients"); + assert!(lib.groups.is_empty() && (12..=16).contains(&lib.len()), "{} swatches", lib.len()); + let radial = lib.iter().filter(|s| matches!(&s.paint, Paint::Gradient(g) if g.gradient.kind == GradientKind::Radial)).count(); + assert!(radial >= 3 && radial * 2 <= lib.len(), "{radial} radial of {}", lib.len()); + for s in lib.iter() { + let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) }; + let (g, n) = (&g.gradient, &s.name); + assert_eq!(g.method, GradientMethod::Perceptual, "{n}"); + assert!((3..=5).contains(&g.stops.len()), "{n}: {} stops", g.stops.len()); + assert!(g.stops.windows(2).all(|w| w[0].offset < w[1].offset), "{n}: rising offsets"); + assert_eq!((g.stops[0].offset, g.stops[g.stops.len() - 1].offset), (0.0, 1.0), "{n}"); + // Unevenly spaced: some gap differs from an even step. + let even = 1.0 / (g.stops.len() - 1) as f32; + assert!(g.stops.windows(2).any(|w| (w[1].offset - w[0].offset - even).abs() > 0.02), "{n}: uneven stops"); + // Every segment's midpoint is off center and within the Gradient panel's 13 to 87%. + let segments = &g.stops[..g.stops.len() - 1]; + assert!(segments.iter().all(|st| (st.midpoint - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&st.midpoint)), "{n}: midpoints"); + // Fully opaque somewhere, and clear at the end (the edge of a radial one). + assert!(g.stops.iter().any(|st| st.opacity == 1.0), "{n}: an opaque stop"); + assert_eq!(g.stops[g.stops.len() - 1].opacity, 0.0, "{n}: clear at the end"); + } + } + + #[test] + fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() { + for b in builtins().filter(|b| b.id != "organic-fades") { + for s in builtin_library(b.id).unwrap().iter() { + if let Paint::Gradient(g) = &s.paint { + assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name); + assert!(g.gradient.stops.iter().all(|st| st.midpoint == 0.5), "{}", s.name); } } } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d6b7eda2c5..05968ced7e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1052,11 +1052,27 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread /// evenly along it by arc length). +/// +/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { + if spec.no_spine { + return None; + } + layout_spine(keys, spec) +} + +/// The spine `spec` stores, unless the blend was made without one. +fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { + spec.spine.as_ref().filter(|_| !spec.no_spine) +} + +/// [`blend_spine`] for placing the steps: a blend made without a spine still places them on the +/// straight lines between its key centers. +fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { if keys.len() < 2 { return None; } - let Some(stored) = &spec.spine else { + let Some(stored) = stored_spine(spec) else { let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; @@ -1127,10 +1143,10 @@ impl Rail { /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. fn new(keys: &[Arc], spec: &BlendSpec) -> Option { let rotate = spec.orientation == BlendOrientation::AlignToPath; - if spec.spine.is_none() && !rotate { + if stored_spine(spec).is_none() && !rotate { return None; } - let (path, anchors) = blend_spine(keys, spec)?; + let (path, anchors) = layout_spine(keys, spec)?; let spine = Spine::new(&path)?; let total = spine.length(); let k = keys.len(); @@ -1192,10 +1208,10 @@ impl Rail { /// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing /// moves. pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { - if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + if stored_spine(spec).is_none() && spec.orientation != BlendOrientation::AlignToPath { return None; } - let spine = blend_spine(keys, spec)?.0.bounds()?; + let spine = layout_spine(keys, spec)?.0.bounds()?; let radius = keys .iter() .filter_map(|k| { @@ -1404,7 +1420,9 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { for j in 1..=n { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; - let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + // Easing needs a spine: a blend made without one keeps its steps evenly spaced. + let eased = rail.is_some() && !spec.no_spine; + let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index ca6b5ed882..4ec7aab1f6 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -503,6 +503,19 @@ impl Document { id } + /// [`Document::next_artboard_id`] when `top` is the highest id the artboards have (`None`: there + /// are none). It reads no artboard unless that id is `u32::MAX`, so a caller that adds many + /// artboards and keeps `top` up to date gets each id in constant time. + pub fn next_artboard_id_above(&self, top: Option) -> u32 { + match top.unwrap_or(0).max(self.retired_artboard_id.get()).checked_add(1) { + Some(id) => { + self.retired_artboard_id.raise(id); + id + } + None => self.next_artboard_id(), + } + } + /// Record that the artboard with `id` was deleted: no later artboard takes its id. pub fn retire_artboard_id(&self, id: u32) { self.retired_artboard_id.raise(id); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304c4e70f6..7233f88a50 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -49,7 +49,7 @@ mod expand; mod mesh; mod warp; -pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; +pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, BlendSpineMode}; #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 75df5eee1f..56405e0d18 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -107,6 +107,11 @@ pub struct BlendSpec { /// on anchor points); `None`: chosen so closed shapes don't twist. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub starts: Vec>, + /// The blend was made without a spine (Create Spine › Never): it has no spine to show, edit, + /// reverse or release, and no easing. Its steps still sit on the straight lines between the + /// key centers. Replace Spine gives it one. + #[serde(default, rename = "noSpine", skip_serializing_if = "is_false", deserialize_with = "lenient_bool")] + pub no_spine: bool, } impl BlendSpec { @@ -116,6 +121,41 @@ impl BlendSpec { } } +/// The Blend panel's Create Spine setting: whether a new blend gets a spine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpineMode { + /// The usual rule: every new blend gets a spine, the straight lines between its key centers. + #[default] + Auto, + /// Every new blend gets a spine, whatever its key objects are. + Always, + /// New blends get no spine. + Never, +} + +impl BlendSpineMode { + /// Every mode, in the order the Blend panel lists them. + pub const ALL: [Self; 3] = [Self::Auto, Self::Always, Self::Never]; + + pub fn id(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|m| m.id() == id) + } + + /// Whether a new blend made with this mode gets a spine. + pub fn creates_spine(self) -> bool { + self != Self::Never + } +} + /// Blend Options set with no blend selected: what new blends start with. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendDefaults { @@ -123,6 +163,29 @@ pub struct BlendDefaults { pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Create Spine (`object.blend.createSpine`). + #[serde(default, rename = "createSpine", skip_serializing_if = "is_auto", deserialize_with = "spine_mode")] + pub create_spine: BlendSpineMode, +} + +fn is_false(v: &bool) -> bool { + !*v +} + +fn is_auto(v: &BlendSpineMode) -> bool { + *v == BlendSpineMode::Auto +} + +/// Reading a native file never fails over a `noSpine` that is not a boolean: the blend keeps its +/// spine. +fn lenient_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(serde_json::Value::deserialize(d)?.as_bool().unwrap_or(false)) +} + +/// Reading the preferences never fails over an unknown Create Spine value: it becomes Auto. +fn spine_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_str().and_then(BlendSpineMode::from_id).unwrap_or_default()) } /// Reading a native file never fails over one bad blend number: out-of-range values clamp, diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index 524f6b205e..b2a9d74f83 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -520,7 +520,7 @@ pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], setting /// the tolerance plus the softness on, and in proportion between them. fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { let Settings { tolerance, softness, measure, .. } = *settings; - let d = refs.iter().map(|r| apart(c, *r, measure)).fold(f64::INFINITY, f64::min); + let d = percent(refs.iter().map(|r| gap(c, *r, measure)).fold(f64::INFINITY, f64::min), measure); if d <= tolerance { 0 } else if d >= tolerance + softness { @@ -530,16 +530,22 @@ fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { } } -/// How far apart `a` and `b` are, as a percentage, measured as `measure` says: the RGB distance of -/// the largest one (black to white), or the largest difference on one channel of the channel's range. -/// A difference of `k` 8-bit steps on one channel is exactly `k / 255 × 100` with channels. -fn apart(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { +/// How far apart `a` and `b` are, measured as `measure` says, before `percent` converts it: the +/// squared RGB distance, or the largest difference on one channel. +fn gap(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { match measure { - Match::Distance => { - let sq: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(); - sq.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0 - } - Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) / 65_535.0 * 100.0, + Match::Distance => a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(), + Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)), + } +} + +/// A `gap` as a percentage: of the largest RGB distance (black to white) with distance, or of the +/// channel's range with channels. A difference of `k` 8-bit steps on one channel is exactly +/// `k / 255 × 100` with channels. +fn percent(gap: f64, measure: Match) -> f64 { + match measure { + Match::Distance => gap.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0, + Match::Channels => gap / 65_535.0 * 100.0, } } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d392449247..60a6b81378 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 207182d40a..3f9b62df21 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -272,6 +272,35 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let parse_chunk = |input: &Value, start: Point| -> Result> { + let values = input + .as_array() + .filter(|points| !points.is_empty() && points.len() <= b::LP_FILL_CHUNK_POINTS) + .ok_or_else(|| bad(C, "each path chunk must contain 1 to 4096 points"))?; + let points: Vec<_> = values + .iter() + .map(|point| { + let pair = point.as_array().filter(|pair| pair.len() == 2).ok_or_else(|| bad(C, "points must be [x,y] pairs"))?; + let x = pair.first().and_then(Value::as_f64).ok_or_else(|| bad(C, "point x must be a number"))?; + let y = pair.get(1).and_then(Value::as_f64).ok_or_else(|| bad(C, "point y must be a number"))?; + let point = Point::new(x, y); + if !finite(point) { + return Err(bad(C, "points must be finite canvas points")); + } + Ok(point) + }) + .collect::>()?; + if points.first() != Some(&start) { + return Err(bad(C, "path chunks must share their boundary point")); + } + Ok(points) + }; + let points = p.get("points"); + let continuation = match p.get("continuation") { + None => &[][..], + Some(input) if points.is_some() => input.as_array().ok_or_else(|| bad(C, "continuation must be an array of path chunks"))?.as_slice(), + Some(_) => return Err(bad(C, "continuation requires points")), + }; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); let group = id_param(p, "group"); @@ -302,7 +331,33 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { .and_then(|n| b::visible_face_at(n, pt)) .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); + let mut targets = std::collections::BTreeSet::from([fid]); + if let Some(points) = points { + let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; + // Use the existing region-crossing probes, so even a face narrower than the distance + // between pointer events is filled. Reversing matches visible_face_at's front first. + let faces: Vec<_> = b::faces(n).into_iter().rev().filter(|f| f.visible).collect(); + let mut face_ids = vec![]; + let mut regions = vec![]; + for f in faces { + if let Some(path) = f.path_data() + && let Some(bounds) = path.bounds() + { + face_ids.push(f.id); + regions.push((po::Region { path: path.clone(), sources: vec![] }, path.to_bezpath(), bounds)); + } + } + let map = b::BuilderMap { regions, ..Default::default() }; + let mut last = pt; + for input in std::iter::once(points).chain(continuation) { + let chunk = parse_chunk(input, last)?; + last = chunk.last().copied().unwrap_or(last); + targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); + } + } + for target in targets { + d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + } Ok((gid, fid)) })?; s.remember_paint(&paint); diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index b63447e228..16cf3b2631 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] @@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "dxfPreset", ty: "string", default: "\"custom\"", - description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it", }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { @@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "\"png\"", description: "png or jpeg: the files placed images are written to, next to the drawing", }, + FormatOption { + name: "includeImages", + ty: "boolean", + default: "true", + description: "placed images are written, linked to their files; false leaves them and their files out", + }, + FormatOption { + name: "includeHatches", + ty: "boolean", + default: "true", + description: "filled areas get a solid hatch after their outlines; false writes the outlines alone", + }, FormatOption { name: "preserve", ty: "string", @@ -133,6 +145,8 @@ struct Params { scale_lineweights: Option, colors: Option, raster_format: Option, + include_images: Option, + include_hatches: Option, preserve: Option, alter_paths: Option, outline_text: Option, @@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?, raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?, + include_images: q.include_images.unwrap_or(d.include_images), + include_hatches: q.include_hatches.unwrap_or(d.include_hatches), preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?, alter_paths: q.alter_paths.unwrap_or(d.alter_paths), outline_text: q.outline_text.unwrap_or(d.outline_text), diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a64c74e035..290f211ad4 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -48,7 +48,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "useArtboards", ty: "boolean", default: "null", - description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document (none when no art shows); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named", + description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document (when no art shows, Export writes none and Save writes it on the first artboard); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named (Save: the first artboard when no art shows)", }, FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, FormatOption { @@ -203,10 +203,15 @@ fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Re } } +/// One file to write: its artboard, region and origin. +type Page = (Option, Rect, Point); + /// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner /// as the origin, or the visible art's bounds with the first artboard's corner. With two or more /// chosen artboards, a central file of the visible art comes first (none when no art shows). -fn pages(doc: &Document, set: &Settings) -> Result, Rect, Point)>> { +/// `saving`: Save always writes the file it names, so with no visible art that file's region is +/// the first artboard, and the second value is true. +fn pages(doc: &Document, set: &Settings, saving: bool) -> Result<(Vec, bool)> { let n = doc.artboards.len(); let named = set.boards.resolve(n).map_err(|e| bad(C, e))?; let boards = match (set.use_artboards, named) { @@ -217,17 +222,25 @@ fn pages(doc: &Document, set: &Settings) -> Result, Rect, Poi }; let corner = |r: Rect| Point::new(r.x0, r.y1); let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect))); + // The visible art's bounds; for Save with no visible art, the first artboard (true). + let visible = || match vectorcraft_render::encode::art_bounds(doc) { + Some(bounds) => Some((bounds, false)), + None if saving => doc.artboards.first().map(|a| (a.rect, true)), + None => None, + }; match boards { Some(boards) => { - let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten(); + let central = (boards.len() > 1).then(visible).flatten(); let each = boards .into_iter() .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))); - central.map(Ok).into_iter().chain(each).collect() + let pages = central.map(|(bounds, _)| Ok(art(bounds))).into_iter().chain(each).collect::>()?; + Ok((pages, central.is_some_and(|(_, blank)| blank))) } None => { - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - Ok(vec![art(bounds)]) + let missing = if saving { "the document has no artboard" } else { "nothing to export: the document has no visible art" }; + let (bounds, blank) = visible().ok_or_else(|| bad(C, missing))?; + Ok((vec![art(bounds)], blank)) } } } @@ -293,16 +306,17 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { encode_document(doc, p, false) } -/// Save keeps a single page's original frame and all native editing data, unlike Export's -/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply. +/// Save writes the pages Export does (the visible art's bounds, or with Use Artboards the chosen +/// artboards), with a smaller preview. A single file, the one the document becomes, carries all +/// the native editing data; with no visible art its page is the first artboard. pub(super) fn encode_save(doc: &Document, p: &Value) -> Result { encode_document(doc, p, true) } fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let set = parse(p)?; - if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) { - return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS")); + if saving && bool_or(p, "selectedOnly", false) { + return Err(bad(C, "EPS Save writes all artwork: use document.export for selected-only EPS")); } let original = doc; let (full, warnings) = crate::cmd::place::document::full_documents(doc); @@ -315,18 +329,16 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { } else { Cow::Borrowed(original) }; - let pages = if saving { - let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?; - let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect; - vec![(Some(board), rect, Point::new(rect.x0, rect.y1))] - } else { - pages(doc, &set)? - }; + let (pages, blank) = pages(doc, &set, saving)?; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } - // The artboards' background colors, behind the art (the editing data keeps them as colors). - let filled = super::export::with_artboard_fills(doc, p, pages.iter().filter_map(|(b, ..)| *b)).map_err(|e| bad(C, e))?; + // The file Save writes alone becomes the document's own, so it carries every artboard. + let whole = saving && pages.len() == 1; + // The artboards' background colors, behind the art (the editing data keeps them as colors); + // a page on the first artboard because no art shows has that artboard's color. + let fills = pages.iter().filter_map(|(b, ..)| *b).chain(blank.then_some(0)); + let filled = super::export::with_artboard_fills(doc, p, fills).map_err(|e| bad(C, e))?; let doc = &*filled; let regions: Vec = pages.iter().map(|(_, region, _)| *region).collect(); let expanded = expand_transparent_tile_images(doc, &set.flatten)?; @@ -360,7 +372,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { origin, title: doc.title.clone(), created, - native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), + native: Some(native(&editing, if whole { None } else { board }, set.include_linked)), ..set.eps.clone() }; let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..301a7c79fe 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -870,8 +870,10 @@ impl SaveJob { _ => encode_all(doc, f.id, &self.params), } .map_err(|e| named_for_save(cmd, e))?; - // A legacy .ai file per artboard. - let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?; + // A legacy .ai file per artboard, or the files the EPS encoder writes: one, or with Use + // Artboards and two or more artboards the central file at the path and a file per + // artboard beside it. + let per_artboard = f.id == "eps" || (f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?); if enc.files.len() != 1 && !per_artboard { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } @@ -1092,6 +1094,25 @@ fn format_options(s: &mut Session, p: &Value) -> Result { } } } + // An EPS save that named artboards without Use Artboards, or with it on, had it on (the + // encoder reads them so): it shows on, with those artboards as the range. + if f.id == "eps" + && let Some(m) = &saved + && m.get("useArtboards").and_then(Value::as_bool) != Some(false) + { + let range = m.get("range").and_then(Value::as_str).filter(|r| !r.trim().is_empty()); + let boards = m + .get("artboards") + .and_then(|a| serde_json::from_value::>(a.clone()).ok()) + .or_else(|| m.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).map(|i| vec![i])) + .filter(|b| !b.is_empty()); + if range.is_some() || boards.is_some() { + v["options"]["useArtboards"]["value"] = json!(true); + if let (None, Some(boards)) = (range, boards) { + v["options"]["range"]["value"] = json!(range_text(&boards)); + } + } + } v["saveFormats"] = SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); Ok(v) @@ -1104,7 +1125,7 @@ pub(super) fn specs() -> Vec { "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 and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to _.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -1158,7 +1179,7 @@ pub(super) fn specs() -> Vec { "Format Options", [], None, - "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default", + "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default (EPS: useArtboards is true when the last save named artboards without it, and range then names them)", always, format_options ), diff --git a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs index 9a7684686a..371cdc05ab 100644 --- a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs +++ b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs @@ -109,23 +109,25 @@ fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() { } } +/// Does PostScript `t` paint the only red the document has, the first artboard's color? RGB, or +/// CMYK PostScript's (its values depend on the color conversion the platform offers, so only its +/// hue is checked). +fn red(t: &str) -> bool { + t.lines().any(|l| { + let mut w: Vec<&str> = l.split_whitespace().collect(); + let Some(op) = w.pop() else { return false }; + let v: Vec = w.iter().filter_map(|n| n.parse().ok()).collect(); + match (op, v.as_slice()) { + ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, + ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, + _ => false, + } + }) +} + #[test] fn eps_and_postscript_print_draw_the_color() { let mut s = session(); - // The only red the document has, the first artboard's color: RGB, or CMYK PostScript's (its - // values depend on the color conversion the platform offers, so only its hue is checked). - let red = |t: &str| { - t.lines().any(|l| { - let mut w: Vec<&str> = l.split_whitespace().collect(); - let Some(op) = w.pop() else { return false }; - let v: Vec = w.iter().filter_map(|n| n.parse().ok()).collect(); - match (op, v.as_slice()) { - ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, - ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, - _ => false, - } - }) - }; let eps = |s: &mut Session, more: Value| { String::from_utf8_lossy(&file(s, with(json!({"format": "eps", "useArtboards": true, "artboard": 0}), &more))).into_owned() }; @@ -138,6 +140,22 @@ fn eps_and_postscript_print_draw_the_color() { assert!(!red(&ps(&mut s, json!({"includeArtboardFill": false}))), "print without it"); } +/// EPS Save draws the color on an artboard's page: with Use Artboards, or on the first artboard +/// when no art shows. The box of the visible art, as Export's, has none. +#[test] +fn eps_save_draws_the_color_on_an_artboard_page() { + let mut s = session(); + let save = |s: &mut Session, more: Value| { + let options = with(json!({"previewFormat": "none", "thumbnails": false}), &more); + String::from_utf8_lossy(&b64(&s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap())).into_owned() + }; + assert!(!red(&save(&mut s, json!({}))), "the art's box"); + assert!(red(&save(&mut s, json!({"useArtboards": true, "range": "1"}))), "the artboard's page"); + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(red(&save(&mut s, json!({}))), "the first artboard when no art shows"); +} + #[test] fn export_for_screens_includes_the_fill_unless_unchecked() { let mut s = session(); diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 8497676836..54839edda4 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() { true ) ); + assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches"); + let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap(); + assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it"); + let custom = dxf::options(&json!({})).unwrap(); + assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both"); assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); assert!(e.contains("custom or cnc"), "{e}"); @@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill +/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves +/// out both, and the options given override it. +#[test] +fn placed_images_and_fill_hatches_can_be_left_out() { + let mut s = super::tests_svg::image_session(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap(); + let counts = |r: &Value| { + let e = entities(&text(r)); + let n = |k: &str| e.iter().filter(|x| *x == k).count(); + (n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len)) + }; + let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w))); + let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + assert_eq!(counts(&all), (1, 1, 1, 1)); + let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap(); + assert_eq!(counts(&r), (1, 1, 0, 0)); + assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap(); + assert_eq!(counts(&r), (1, 0, 0, 0)); + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + // With a path, no image file is written next to the drawing. + let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cut.dxf").to_string_lossy().to_string(); + let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap(); + assert!(r.get("linked").is_none(), "{r}"); + let files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(files, ["cut.dxf"]); + let _ = std::fs::remove_dir_all(dir); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. @@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() { json!({"rasterFormat": "gif"}), json!({"preserve": "both"}), json!({"alterPaths": "yes"}), + json!({"includeImages": "no"}), + json!({"includeHatches": 0}), ] { let e = s.execute("document.exportDxf", &p).unwrap_err().to_string(); assert!(e.contains("DXF"), "{p}: {e}"); @@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() { "scaleLineweights", "colors", "rasterFormat", + "includeImages", + "includeHatches", "preserve", "alterPaths", "outlineText", diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index 2f69a9df39..d27809fa78 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -109,6 +109,40 @@ fn unlinked(g: &Gradient) -> Gradient { g } +/// Give each saved preset in `saved` a name no built-in preset or earlier saved one has (any case): +/// a clash becomes the first free " N", N from 2. Returns the renames as (old, new). +pub(crate) fn rename_clashing_presets(saved: &mut [GradientPreset]) -> Vec<(String, String)> { + if saved.is_empty() { + return vec![]; + } + let builtin: std::collections::HashSet = builtin_presets().map(|e| e.name.to_ascii_lowercase()).collect(); + let mut taken: std::collections::HashSet = + builtin.iter().cloned().chain(saved.iter().map(|p| p.name.trim().to_ascii_lowercase())).collect(); + let mut kept = std::collections::HashSet::new(); + // The next suffix to try for each name, so many presets with one name take linear time. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + let mut renamed = vec![]; + for p in saved { + let key = p.name.trim().to_ascii_lowercase(); + if !builtin.contains(&key) && kept.insert(key.clone()) { + continue; + } + let base = p.name.trim(); + let n = next.entry(key).or_insert(2); + let name = loop { + let candidate = format!("{base} {n}"); + *n += 1; + if !taken.contains(&candidate.to_ascii_lowercase()) { + break candidate; + } + }; + taken.insert(name.to_ascii_lowercase()); + kept.insert(name.to_ascii_lowercase()); + renamed.push((std::mem::replace(&mut p.name, name.clone()), name)); + } + renamed +} + impl Session { /// The Gradient panel's presets: the built-in ones by library, then the saved ones. pub fn gradient_presets(&self) -> Vec { diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 7d7c21227a..609936ecf7 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -1,5 +1,7 @@ use std::collections::BTreeSet; +use vectorcraft_doc::live::BlendSpineMode; + use super::common::{ DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, spacing_param, starts_param, wrap, @@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, k spec.spine = Some(path); } +/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine. +fn gives_easing(p: &Value) -> bool { + ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) +} + +/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null. +fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))), + } +} + pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.make"; let roots = roots_param(s, p)?; @@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { } let starts = starts_param(p, &roots)?; let base = first_blend(&s.doc()?.doc, &roots); + let defaults = s.prefs.blend_options.unwrap_or_default(); + // Create Spine applies to a new blend; one taking more keys keeps whether it has a spine. + let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?; let cur = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, - _ => s.prefs.blend_options.unwrap_or_default(), + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults }, + _ => defaults, }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); - let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), + let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine), + _ => (Default::default(), 0.0, !create_spine.creates_spine()), }; + if no_spine && gives_easing(p) { + return Err(bad(C, "the blend has no spine")); + } let ease = ease_param(p, C, ease0)?; let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { @@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -346,9 +367,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; - s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d }); return Ok(json!({ "defaults": true })); }; + if gives_easing(p) && any_without_spine(s) { + return Err(bad(C, "the blend has no spine")); + } let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); let ease = ease_param(p, C, cur.ease)?; @@ -384,6 +408,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + v["hasSpine"] = json!(!spec.no_spine); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) && let Some((path, anchors)) = live::blend_spine(children, &spec) && let Some(sp) = path.subpaths.first() @@ -403,6 +428,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { }) } +/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads +/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs. +pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.createSpine"; + let mut d = s.prefs.blend_options.unwrap_or_default(); + let mode = spine_mode_param(p, C, "mode", d.create_spine)?; + if mode != d.create_spine { + d.create_spine = mode; + s.prefs.blend_options = Some(d); + } + Ok(json!({ "mode": mode.id() })) +} + pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { @@ -507,6 +545,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + // A blend made without a spine gets this one. + spec.no_spine = false; spec.spine = Some(spine); spec.key_anchors.clear(); keys_to_spine(children, spec); @@ -517,7 +557,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { ok() } +/// Is any selected blend one made without a spine? +fn any_without_spine(s: &Session) -> bool { + let Ok(st) = s.doc() else { return false }; + selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) +} + pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + if any_without_spine(s) { + return Err(bad("object.blend.reverseSpine", "the blend has no spine")); + } edit_blends(s, "Reverse Spine", |children, spec| { if spec.spine.is_some() && pin(children, spec) { // The keys keep their anchors, which now count from the other end. diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869d..5480f7ae58 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ - blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, - spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, + blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}", has_doc, blend_make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", + "{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -40,6 +40,15 @@ pub fn specs() -> Vec { always, blend_options ), + cmd!( + "object.blend.createSpine", + "Create Spine", + [], + None, + "{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}", + always, + blend_create_spine + ), cmd!( "object.blend.expand", "Expand", @@ -54,7 +63,7 @@ pub fn specs() -> Vec { "Replace Spine", ["Object", "Blend"], None, - "{} use the selected path as the spine of the selected blend (the path is consumed)", + "{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one", has_blend, blend_replace_spine ), @@ -63,7 +72,7 @@ pub fn specs() -> Vec { "Reverse Spine", ["Object", "Blend"], None, - "{} reverse the order of the key objects along the spine", + "{} reverse the order of the key objects along the spine; an error when a selected blend has no spine", has_blend, blend_reverse_spine ), @@ -72,7 +81,7 @@ pub fn specs() -> Vec { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index b3992ac2a9..1dd1548363 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -1306,13 +1306,16 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { let symbols = d.symbol_boxes(); let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect(); let mut converted = vec![]; + // The highest artboard id the document has, kept up to date as artboards are added. + let mut top = d.artboards.iter().map(|a| a.id).max(); for (id, b) in boxes { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } // Named one past the highest artboard id the document has, whatever id it is given. - let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1)); - let aid = d.next_artboard_id(); + let number = top.map_or(1, |m| m.saturating_add(1)); + let aid = d.next_artboard_id_above(top); + top = top.max(Some(aid)); d.artboards.push(vectorcraft_doc::Artboard { id: aid, name: format!("Artboard {number}"), diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index eb019a88e9..5c66ec8442 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 50f9ee48e8..2d16bbc683 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -760,14 +760,16 @@ fn install(s: &mut Session, p: &Value) -> Result { // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there - // was none, no module goes with the grant, and the grant stops counting for this session. A - // grants file that is too large or doesn't hold a list approves nothing, and the new module - // stays; one that can't be opened or read may approve the old bytes once it reads again. + // was none, no module goes with the grant, and the grant stops counting for every session in + // the process attached to the folder. A grants file that is too large or doesn't hold a list + // approves nothing, and the new module stays; one that can't be opened or read may approve + // the old bytes once it reads again. + let writes = s.grant_writes(); if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); stored = !back && holds_new(s); if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&m.id); + s.ignore_stored_grant(&m.id, writes); } } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 51470e742d..5c03317fa9 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -676,6 +676,7 @@ pub(super) const DENIED: &[&str] = &[ "addon.pen.nextSegment", "effect.refreshDeferred", "graphicStyle.setOptions", + "object.blend.createSpine", "object.blend.options", "object.liquify", "object.pathfinder.options", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a92b4095a9..af09b032da 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -687,6 +687,10 @@ impl Session { for e in self.prefs.keep_folders(&mut p) { log::warn!("{e}"); } + // Saved gradient presets get names no built-in preset or other saved one has. + for (old, new) in super::gradpresets::rename_clashing_presets(&mut p.gradient_presets) { + log::warn!("saved gradient preset {old} is listed as {new}: another preset has its name"); + } // The edit the active tool computes on another thread is applied first (the Preferences // dialog applies its settings here, outside a command). A failure to apply it was the // edit's own, and the preferences apply all the same. @@ -765,7 +769,7 @@ impl Session { pub fn specs() -> Vec { vec![ - cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get), + cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get), cmd!( query "prefs.set", "Set Preference", @@ -776,7 +780,7 @@ pub fn specs() -> Vec { set ), cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset), - cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list), + cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list), cmd!( query "prefs.export", "Export Preferences", @@ -882,8 +886,42 @@ fn import(s: &mut Session, p: &Value) -> Result { Ok(json!({ "imported": values.len() })) } +/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks. +const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not folders. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the +/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside +/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is +/// left out. An empty value (the default folder) and a scratch +/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are. +fn shown_prefs(prefs: &Prefs) -> Value { + let mut all = prefs.to_json(); + if crate::file_access::current().is_none() { + return all; + } + // The app trims a folder before it uses it. + let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err(); + for key in PATH_PREFS { + let Some(path) = all[key].as_str() else { continue }; + if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) { + all[key] = Value::Null; + } + } + if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) { + for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) { + if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) { + settings.remove("folder"); + } + } + } + all +} + fn get(s: &mut Session, p: &Value) -> Result { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); match str_param(p, "key") { Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()), Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))), @@ -911,7 +949,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect())) } @@ -925,11 +963,11 @@ fn reset(s: &mut Session, p: &Value) -> Result { let mut next = s.prefs.clone(); next.reset(cat); s.apply_prefs(next); - Ok(json!({ "prefs": s.prefs.to_json() })) + Ok(json!({ "prefs": shown_prefs(&s.prefs) })) } fn list(s: &mut Session, _: &Value) -> Result { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Array( PREF_SPECS .iter() diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index b75066ec6a..3424da3ddd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -17,7 +17,9 @@ //! store can, an announcement that lasts as long as the app ([`RecoveryStore::announce`]: the //! browser's Web Locks). Only areas nobody holds any more (their app is gone) are offered, and //! while their copies are restored or discarded the area is held, so two apps launched together -//! never both take it. +//! never both take it. An area's lock file or folder can be removed while its app runs, so each +//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or +//! takes a new area. //! //! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so //! when unsure an area counts as held, and its app repairs what another app did meanwhile: each @@ -100,6 +102,12 @@ pub trait RecoveryStore: Send + Sync { fn lock(&self, _area: &str) -> std::result::Result { Ok(Lock::Unsupported) } + /// Does `hold`, which [`RecoveryStore::lock`] gave for area `area`, still hold it? A lock on a + /// file or folder that was removed or replaced since holds nothing; when that can't be told now, + /// it does (default: it does). + fn still_held(&self, _area: &str, _hold: &(dyn std::any::Any + Send + Sync)) -> bool { + true + } /// Stores without locks: also hold area `area` in a way other apps can see that lasts exactly as /// long as this app runs, if the store has one (the browser's Web Locks) → it, until dropped. fn announce(&self, _area: &str) -> Option { @@ -416,6 +424,16 @@ impl RecoveryStore for FolderStore { Self::create_area(&dir)?; Self::lock_area(&dir) } + fn still_held(&self, area: &str, hold: &(dyn std::any::Any + Send + Sync)) -> bool { + let (Some(file), Ok(dir)) = (hold.downcast_ref::(), self.area(area)) else { return true }; + let path = dir.join(LOCK_FILE); + // Only a lock file or folder that is gone, or another one in its place, ends the hold. One + // that can't be read now (a network folder that doesn't answer) still counts as held. + match std::fs::symlink_metadata(&path) { + Ok(_) => Self::own_area(&dir).is_ok() && Self::still_named(file, &path), + Err(e) => e.kind() != std::io::ErrorKind::NotFound, + } + } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty // (copies came back meanwhile) stays. The caller still holds the lock here. @@ -457,6 +475,10 @@ pub struct Recovery { /// heartbeat it wrote there: that one doesn't make them look running, a newer one does (their /// app was only paused and came back). adopted: BTreeMap, + /// Copies in an area this session had to leave (`keep_hold`), by document uid, with their + /// store: each stays until its document has a copy in the new area, or needs none. They are + /// still this session's: never offered or discarded as a crash's here. + left: BTreeMap, String)>, } impl Recovery { @@ -526,14 +548,81 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option) /// Refresh this session's hold on its area (the app does this every [`HEARTBEAT_EVERY`] seconds, /// so also on its first frame after its timers were paused): its heartbeat in a store without -/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now). -/// Best effort: a missed beat only lets another app offer the copies once three intervals have -/// passed. +/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now), +/// and those whose only copy is in an area the session left (`keep_hold`). Best effort: a missed +/// beat only lets another app offer the copies once three intervals have passed. pub fn heartbeat(s: &mut Session) -> Vec { + let mut lost = keep_hold(s); if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) { let _ = beat(own.store.as_ref(), &own.area); } - repair(s) + // A copy that was being written when the session left its area finished there. A document + // that gets no copy now (a complex one) keeps the copy it has. + lost.extend( + s.recovery + .left + .keys() + .filter(|&&uid| s.documents().iter().any(|d| d.uid == uid && d.recovery.is_none() && skip_reason(s, d).is_none())) + .copied(), + ); + lost.extend(repair(s)); + lost.sort_unstable(); + lost.dedup(); + lost +} + +/// Check that this session's lock still holds its area (stores with locks). The area's lock file, +/// or the whole area, can be removed while the app runs (by the user, a cleaner, another program), +/// and then another app can lock the area and offer or delete this session's copies as a crash's. +/// The area is locked again, made again first if it went (its copies are then written again). +/// When another app holds it now, the session takes a new area, and each copy left in the old one +/// stays until its document has a copy in the new one. → the documents whose copies must be +/// written again. +fn keep_hold(s: &mut Session) -> Vec { + drop_left_done(s); + let Some(own) = &mut s.recovery.own else { return vec![] }; + if own.beats || own.hold.as_deref().is_none_or(|h| own.store.still_held(&own.area, h)) { + return vec![]; + } + let (store, area) = (own.store.clone(), own.area.clone()); + // The lock held is on a file the area no longer has, so it holds nothing: it goes first (a + // removed file this app still has open can keep a new lock file from being made). + own.hold = None; + if let Ok(Lock::Held(h)) = store.lock(&area) { + own.hold = Some(h); + return repair(s); + } + let mut lost = vec![]; + for st in &mut s.docs { + if let Some(c) = st.recovery.take() { + s.recovery.left.insert(st.uid, (store.clone(), c.file)); + lost.push(st.uid); + } + } + s.recovery.own = None; + // Best effort: when no area can be taken now, the next recovery save says why. + let _ = claim(s, &store); + lost +} + +/// The copies left in an area this session left whose documents were saved, reverted or closed +/// since go. +fn drop_left_done(s: &mut Session) { + let done: Vec = s.recovery.left.keys().filter(|&&uid| !s.documents().iter().any(|d| d.uid == uid && d.is_dirty())).copied().collect(); + for uid in done { + drop_left(s, uid); + } +} + +/// Document `uid`'s copy in an area this session left goes. A copy that can't be removed now stays +/// this session's, and a later heartbeat removes it. +fn drop_left(s: &mut Session, uid: u64) { + let Some((store, file)) = s.recovery.left.get(&uid) else { return }; + let doc = store.remove(&format!("{file}{DOC_EXT}")); + let meta = store.remove(&format!("{file}{META_EXT}")); + if doc.is_ok() && meta.is_ok() { + s.recovery.left.remove(&uid); + } } /// Forget this session's copies that are missing from its area, so the next recovery save writes @@ -555,10 +644,13 @@ pub fn repair(s: &mut Session) -> Vec { /// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the /// session's lifetime (locked, or with a heartbeat and an announcement where the store has them). pub fn claim(s: &mut Session, store: &Arc) -> Result { - if let Some(own) = &s.recovery.own { - if own.store.location() == store.location() { + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + // Documents whose copies went are left without one, so the caller writes them again. + keep_hold(s); + if let Some(own) = &s.recovery.own { return Ok(own.area.clone()); } + } else if s.recovery.own.is_some() { // The store moved (the preference names another folder): copies start again there. release(s); } @@ -588,6 +680,11 @@ pub fn release(s: &mut Session) { for st in &mut s.docs { st.recovery = None; } + // Copies left in an area this session had to leave go too. + let left: Vec = s.recovery.left.keys().copied().collect(); + for uid in left { + drop_left(s, uid); + } let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return }; let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { @@ -750,12 +847,16 @@ pub struct RecoveryJob { pub title: String, meta: Meta, save: SaveJob, + /// The store the copy goes to (the preference can name another folder before it is finished). + store: Arc, } impl RecoveryJob { /// Encode the snapshot and store it with its details and the images the area doesn't have - /// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`. - pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result { + /// yet, in the store the job was made for → `{file, title, bytes (the document's), imageBytes + /// (images written now)}`. + pub fn write(&self) -> std::result::Result { + let store = self.store.as_ref(); let doc = self.save.doc(); // A linked image keeps its preview, as a save writes it. let linked = doc.linked_only_images(); @@ -808,13 +909,35 @@ impl RecoveryJob { /// Once stored: the document (if still open) remembers the copy, so it isn't written again /// while unchanged and goes when the document is saved or closed. A document saved or closed - /// meanwhile has nothing left to recover: its copy goes at once. + /// meanwhile has nothing left to recover: its copy goes at once. So does a copy written in an + /// area the session left meanwhile (`keep_hold`), and its document gets a new one. pub fn finish(self, s: &mut Session) { let snapshot = self.save.snapshot().clone(); let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot }; + let area = self.file.split_once('/').map(|(a, _)| a); + let current = s.recovery.own.as_ref().is_some_and(|o| o.store.location() == self.store.location() && Some(o.area.as_str()) == area); match s.document_mut(self.uid).filter(|st| st.is_dirty()) { - Some(st) => st.recovery = Some(copy), - None => remove_copy(s, &self.file), + Some(st) if current => { + st.recovery = Some(copy); + drop_left(s, self.uid); + } + // Written in an area left meanwhile: kept, in place of the copy left there before, + // until the document has a copy in the new area. + Some(st) if st.recovery.is_none() => { + let before = s.recovery.left.insert(self.uid, (self.store.clone(), self.file.clone())); + // The copy left there before, unless this one was written over it. Best effort, + // as in `remove_copy`. + if let Some((store, file)) = before.filter(|(old, f)| old.location() != self.store.location() || *f != self.file) { + let _ = store.remove(&format!("{file}{DOC_EXT}")); + let _ = store.remove(&format!("{file}{META_EXT}")); + } + } + // Best effort, as in `remove_copy`. + _ => { + let _ = self.store.remove(&format!("{}{DOC_EXT}", self.file)); + let _ = self.store.remove(&format!("{}{META_EXT}", self.file)); + drop_left(s, self.uid); + } } } } @@ -867,6 +990,11 @@ fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String { /// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified /// documents that get none). pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, Vec)> { + // A lost lock is taken again first: copies that go with it are written again, even of + // documents unchanged since. + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + keep_hold(s); + } // Copies another app removed are written again. repair(s); let skipped: Vec = s @@ -923,7 +1051,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec Result<()> { return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into())); } for j in jobs { - j.write(store.as_ref()).map_err(EngineError::Other)?; + j.write().map_err(EngineError::Other)?; j.finish(s); } Ok(()) @@ -966,6 +1094,8 @@ pub fn forget(s: &mut Session, uid: u64) { if s.script_in_run { return; } + // So does a copy of it left in an area this session left. + drop_left(s, uid); let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return }; remove_copy(s, ©.file); } @@ -975,12 +1105,16 @@ pub fn forget_all(s: &mut Session) { release(s); } -/// The copies of documents with no unsaved changes left (undone back to the saved state) go. +/// The copies of documents with no unsaved changes left (undone back to the saved state) go, and +/// the copies left in an area this session left of documents closed since. pub fn forget_clean(s: &mut Session) { let uids: Vec = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect(); for uid in uids { forget(s, uid); } + if !s.script_in_run { + drop_left_done(s); + } } fn save(s: &mut Session, _: &Value) -> Result { @@ -988,7 +1122,7 @@ fn save(s: &mut Session, _: &Value) -> Result { let (jobs, skipped) = jobs(s, &store)?; let mut saved = vec![]; for j in jobs { - saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?); + saved.push(j.write().map_err(EngineError::Other)?); j.finish(s); } forget_clean(s); @@ -1057,9 +1191,15 @@ fn open_copies(s: &Session) -> Vec { s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect() } +/// The copies of documents open now in this session that are in an area it left, in `store`. +fn left_copies(s: &Session, store: &dyn RecoveryStore) -> Vec { + s.recovery.left.values().filter(|(st, _)| st.location() == store.location()).map(|(_, f)| f.clone()).collect() +} + fn list(s: &mut Session, _: &Value) -> Result { let store = need_store(s, "file.recovery.list")?; let open = open_copies(s); + let left = left_copies(s, store.as_ref()); let copies = copies_in(&store.list().map_err(EngineError::Other)?); let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { @@ -1071,7 +1211,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, - "open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running, + "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } // A gone app's empty area is tidied away. @@ -1095,7 +1235,14 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> { return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)"))); } - let picked = |c: &str| file.is_none_or(|f| f == c); + // A copy this session left in an area it had to leave is still its own. + let left = left_copies(s, store); + if let Some(f) = file + && left.iter().any(|c| c == f) + { + return Err(bad(cmd, format!("`{f}` is the copy of a document open now"))); + } + let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); let mut out = vec![]; for area in areas { diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 2dda356146..ec458a0c9b 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); } +#[test] +fn convert_to_point_type_turns_block_off_keeping_its_sizes() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::>(); + let fitted = sizes(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + assert!(!t.dynamic_text, "point type has Dynamic Text off"); + assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set"); + assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText"); + // Back to area type, the frame is not refitted: Block stays off. + s.execute("type.convertToAreaType", &json!({})).unwrap(); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + assert!(!t.dynamic_text, "Block does not come on again"); + assert_eq!(sizes(&t), fitted); + // Undo gives the Block frame back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_eq!(sizes(&t), fitted); +} + #[test] fn bad_params_and_other_type_are_refused() { let mut s = session(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index faaaf2145d..bb0e426c9b 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}", has_selection, to_point ), @@ -410,6 +410,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1, _ => x0, }; + // Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so + // converting back to area type does not turn it on again. + t.dynamic_text = false; t.kind = TextKind::Point; t.xf *= Affine::translate((ox, baseline)); refresh_bounds(t); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f1f188f00c..745a6fb62c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,10 +171,6 @@ pub struct Session { /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder /// ([`Session::attach_scripts_dir`]). scripts_dir: Option, - /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: - /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful - /// [`Session::store_grant`] for the id takes it out. - ignored_plugin_grants: std::sync::Mutex>, /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option, diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index a8f0c18336..c3b3084e21 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -1787,6 +1787,10 @@ impl Session { if let Some(e) = job.host.dialogs.take_failure() { return Some(self.script_failed(job, &e)); } + // A canceled request ends a sleep or a wait at this pump. + if vectorcraft_doc::cancel::check().is_err() { + self.script_stop.set(); + } if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); @@ -1798,9 +1802,6 @@ impl Session { let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; return Some(self.script_failed(job, &e)); } - if vectorcraft_doc::cancel::check().is_err() { - self.script_stop.set(); - } job.host.calls_this_slice.set(0); let outcome = run_slice(self, &mut job); match outcome { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 83a0367751..2d850fd44d 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -87,7 +87,6 @@ impl Session { color_settings_file: None, plugin_dir: None, scripts_dir: None, - ignored_plugin_grants: Default::default(), plugin_output: None, plugin_install_approval: None, plugin_grants_from_requests: false, diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index a56b29d9db..4943353ee0 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -89,6 +89,24 @@ static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); /// The state of each plug-in folder's storage, shared by every storage made for the folder until /// the plug-in is removed, which closes it and drops it. static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); +/// The grants of plug-in stores that the sessions of the process don't count, and when each was +/// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is. +static IGNORED_PLUGIN_GRANTS: Mutex = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 }); +/// How many of the latest grant writes [`IgnoredGrants`] keeps. +const MAX_GRANT_WRITES_KEPT: usize = 256; + +/// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id. +struct IgnoredGrants { + /// The grants that should be gone but could not be removed: no session in the process counts + /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id + /// ([`Session::store_grant`]). + ignored: BTreeSet<(PathBuf, String)>, + /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the + /// count of writes it made ([`IgnoredGrants::writes`]). + written: BTreeMap<(PathBuf, String), u64>, + /// How many grant writes the sessions of the process have made ([`Session::grant_writes`]). + writes: u64, +} /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -100,6 +118,37 @@ pub(crate) fn file_stem(name: &str) -> String { format!("{}-{hash:016x}", safe.trim()) } +/// The folder `dir` spelled one way for every path to it, so that sessions attached to it by +/// different paths share [`IGNORED_PLUGIN_GRANTS`]: the canonical path when the folder exists, else +/// the canonical path of its nearest folder that exists followed by the rest of the path, with `.` +/// and `..` taken out by name. The path as given when it has no absolute form (the current folder +/// can't be read). +pub(crate) fn plugin_store_key(dir: &Path) -> PathBuf { + if let Ok(path) = std::fs::canonicalize(dir) { + return path; + } + let Ok(absolute) = std::path::absolute(dir) else { return dir.to_path_buf() }; + let mut parts: Vec = Vec::new(); + for part in absolute.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!(parts.last(), Some(std::path::Component::Normal(_))) { + parts.pop(); + } + } + _ => parts.push(part), + } + } + for kept in (1..=parts.len()).rev() { + let base: PathBuf = parts[..kept].iter().collect(); + if let Ok(found) = std::fs::canonicalize(&base) { + return parts[kept..].iter().fold(found, |path, part| path.join(part)); + } + } + parts.iter().collect() +} + /// The regular files of `dir` with the extension `ext` (any case), by file name, at most /// [`MAX_STORE_FILES`]; none when the folder can't be read. fn list_files(dir: &Path, ext: &str) -> Vec { @@ -191,6 +240,9 @@ fn lock_disabled(_: &Path) -> Option { thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when + /// another program holds it open. + pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then /// reports a failure, as when the last flush of a copy fails. pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -205,7 +257,15 @@ thread_local! { /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. fn take(dir: &Path, file: &str) -> bool { let path = dir.join(file); - match std::fs::remove_file(&path) { + #[cfg(test)] + let removed = if file == GRANTS_FILE && GRANTS_DELETE_FAILS.get() { + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + } else { + std::fs::remove_file(&path) + }; + #[cfg(not(test))] + let removed = std::fs::remove_file(&path); + match removed { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { log::warn!("can't remove {}: {e}", path.display()); false @@ -344,9 +404,6 @@ impl Session { let disabled = read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); safe_mode::set_disabled(disabled); - if self.plugin_dir.as_ref() != Some(&dir) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).clear(); - } self.plugin_dir = Some(dir); self.load_stored_plugins(); } @@ -357,7 +414,8 @@ impl Session { /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and /// stays in the folder with its grant. A grant for an id whose stored module lists no /// permissions is forgotten; one that can't be removed from the grants file doesn't count for - /// the rest of the session ([`Session::plugin_approver`]). + /// any session in the process attached to the folder until the file can be written for the id + /// ([`Session::ignore_stored_grant`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -421,8 +479,11 @@ impl Session { Ok(file) => file.names(id), Err(e) => e.unread, }; - if named && !self.store_grant(id, None) && self.holds_plugin_grant(id) { - self.ignore_stored_grant(id); + if named { + let writes = self.grant_writes(); + if !self.store_grant(id, None) && self.holds_plugin_grant(id) { + self.ignore_stored_grant(id, writes); + } } } } @@ -504,12 +565,16 @@ impl Session { } } - /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session - /// doesn't count ([`Session::ignore_stored_grant`]). + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process + /// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked + /// out on each call, so a path whose link now leads elsewhere is matched with where it leads. fn counted_grants(&self, dir: &Path) -> BTreeMap { - let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()); + let key = plugin_store_key(dir); let mut grants = stored_grants(dir); - grants.retain(|id, _| !ignored.contains(id)); + let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) { + grants.remove(id); + } grants } @@ -562,20 +627,53 @@ impl Session { /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now /// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts - /// again ([`Session::ignore_stored_grant`]). + /// again, for every session in the process attached to the store ([`Session::ignore_stored_grant`]). pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { let done = self.write_grant(id, grant); - if done { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id); + if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + shared.ignored.remove(&entry); + shared.writes = shared.writes.saturating_add(1); + let writes = shared.writes; + shared.written.insert(entry, writes); + if shared.written.len() > MAX_GRANT_WRITES_KEPT + && let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone()) + { + shared.written.remove(&oldest); + } } done } - /// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next - /// succeeds for it or another store is attached: for a grant that should be gone and could not - /// be removed, which would otherwise approve the bytes it covers. - pub(crate) fn ignore_stored_grant(&self, id: &str) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string()); + /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). + /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. + pub(crate) fn grant_writes(&self) -> u64 { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes + } + + /// How many grant writes the process keeps ([`IgnoredGrants::written`]). + #[cfg(test)] + pub(crate) fn grant_writes_kept() -> usize { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len() + } + + /// Stop counting the attached store's grant for the plug-in `id`, in every session in the + /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in + /// one of them: for a grant that should be gone and could not be removed, which would otherwise + /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed + /// write: when a session has written the store's grants for `id` since, the file holds what that + /// write asked for, and the grant keeps counting. A write no longer among the latest + /// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the + /// folder reads `grants.json` as it is. + pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) { + if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + if shared.written.get(&entry).is_none_or(|at| *at <= writes) { + shared.ignored.insert(entry); + } + } } fn write_grant(&self, id: &str, grant: Option) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 91c95cb630..abf65d834a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -133,6 +133,7 @@ mod placed_document; mod plugins; mod prefs; mod prefs_file; +mod prefs_roots; mod previewbounds; mod print; mod printadvanced; diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 059357ddb3..4b391c5714 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -418,3 +418,179 @@ fn native_blend_easing_values_clamp_and_non_finite_become_default() { assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); } } + +fn create_spine(s: &mut Session, p: Value) -> Value { + s.execute("object.blend.createSpine", &p).unwrap()["mode"].clone() +} + +/// Two squares 100 pt apart blended with `p`; the blend. +fn make(s: &mut Session, y: f64, p: Value) -> NodeId { + let (a, b) = (rect(s, 0.0, y, 10.0), rect(s, 100.0, y, 10.0)); + let mut p = p; + p["ids"] = json!([a.0, b.0]); + p["steps"] = json!(3); + id_of(&s.execute("object.blend.make", &p).unwrap()) +} + +#[test] +fn create_spine_sets_whether_new_blends_get_a_spine() { + let mut s = session(); + assert_eq!(create_spine(&mut s, json!({})), "auto", "Auto until one is chosen"); + let spined = make(&mut s, 0.0, json!({})); + assert!(!spec(&s, spined).1.no_spine); + let depth = s.doc().unwrap().history.undo.len(); + assert_eq!(create_spine(&mut s, json!({"mode": "never"})), "never"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step"); + assert_eq!(create_spine(&mut s, json!({})), "never"); + // The Blend Options set with nothing selected keep it, and it keeps them. + s.execute("select.none", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"steps": 7})).unwrap(); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(s.prefs.blend_options.unwrap().spacing, BlendSpacing::Steps(7)); + // A new blend has no spine; the blend made before keeps its own. + let bare = make(&mut s, 50.0, json!({})); + assert!(spec(&s, bare).1.no_spine); + assert!(!spec(&s, spined).1.no_spine, "blends already made keep their spine"); + sel(&mut s, &[bare]); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["hasSpine"], json!(false)); + assert!(info.get("spine").is_none(), "{info}"); + sel(&mut s, &[spined]); + assert_eq!(s.execute("object.blend.info", &json!({})).unwrap()["hasSpine"], json!(true)); + // `createSpine` on Make chooses for that blend only. + let forced = make(&mut s, 100.0, json!({"createSpine": "always"})); + assert!(!spec(&s, forced).1.no_spine); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(create_spine(&mut s, json!({"mode": "always"})), "always"); + let always = make(&mut s, 150.0, json!({})); + assert!(!spec(&s, always).1.no_spine); + assert!(spec(&s, bare).1.no_spine, "blends already made keep having no spine"); + let none = make(&mut s, 200.0, json!({"createSpine": "never"})); + assert!(spec(&s, none).1.no_spine); + // The setting survives a round trip through the preferences file; Auto is not written. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Always); + assert_eq!(s.prefs.to_json()["blendOptions"]["createSpine"], json!("always")); + create_spine(&mut s, json!({"mode": "auto"})); + assert!(s.prefs.to_json()["blendOptions"].get("createSpine").is_none()); + let mut odd = s.prefs.to_json(); + odd["blendOptions"]["createSpine"] = json!("sometimes"); + let unknown: Prefs = serde_json::from_value(odd).unwrap(); + assert_eq!(unknown.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Auto, "an unknown value reads as Auto"); +} + +#[test] +fn create_spine_refuses_bad_values_and_changes_nothing() { + let mut s = session(); + create_spine(&mut s, json!({"mode": "never"})); + let prefs = s.prefs.blend_options; + for bad in [json!({"mode": "sometimes"}), json!({"mode": 1}), json!({"mode": true}), json!({"mode": ["never"]}), json!({"mode": "Never"})] { + let e = s.execute("object.blend.createSpine", &bad).unwrap_err().to_string(); + assert!(e.contains("`mode` must be one of auto, always, never"), "{bad}: {e}"); + assert_eq!(s.prefs.blend_options, prefs, "{bad}"); + } + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"createSpine": "x"}), json!({"createSpine": 0}), json!({"createSpine": "never", "easingType": "easeIn"}), json!({"ramp": 30})] + { + let mut p = bad.clone(); + p["ids"] = json!([a.0, b.0]); + assert!(s.execute("object.blend.make", &p).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "{bad}: nothing made"); + } + assert!(s.execute("object.blend.createSpine", &json!({"spine": "never"})).is_err(), "an unknown key"); +} + +#[test] +fn a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release() { + let mut s = session(); + let g = make(&mut s, 0.0, json!({"createSpine": "never"})); + sel(&mut s, &[g]); + let before = node(&s, g); + let depth = s.doc().unwrap().history.undo.len(); + // Reverse Spine, easing and the spine point commands refuse it and change nothing. + let refused = [ + ("object.blend.reverseSpine", json!({})), + ("object.blend.options", json!({"easingType": "easeOut"})), + ("object.blend.options", json!({"ramp": 40})), + ("object.blend.spine.addAnchor", json!({"x": 50, "y": 5})), + ("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 0, "y": 50})), + ]; + for (cmd, p) in refused { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{cmd}: {e}"); + assert_eq!((node(&s, g), s.doc().unwrap().history.undo.len()), (before.clone(), depth), "{cmd}"); + } + // Spacing, Orientation and Shift still apply; Align to Path makes no spine. + s.execute("object.blend.options", &json!({"steps": 2, "orientation": "path", "colorShift": 20})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.spacing, sp.orientation, sp.color_shift, sp.no_spine), (BlendSpacing::Steps(2), BlendOrientation::AlignToPath, 20.0, true)); + assert!(s.execute("object.blend.info", &json!({})).unwrap().get("spine").is_none()); + // Taking in another key keeps it without a spine, whatever Create Spine says. + let c = rect(&mut s, 200.0, 0.0, 10.0); + sel(&mut s, &[g, c]); + create_spine(&mut s, json!({"mode": "always"})); + let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + assert_eq!(spec(&s, g).0.len(), 3); + assert!(spec(&s, g).1.no_spine && spec(&s, g).1.spine.is_none()); + // Release gives back the keys and no spine path. + sel(&mut s, &[g]); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!((r["ids"].as_array().map(Vec::len), &r["spines"]), (Some(3), &json!([]))); + s.execute("edit.undo", &json!({})).unwrap(); + // Replace Spine gives it one: then it reverses and eases like any other. + let line = id_of(&s.execute("path.create", &json!({"d": "M0 100 L200 150"})).unwrap()); + sel(&mut s, &[g, line]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert!(!sp.no_spine && sp.spine.is_some()); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["hasSpine"], &info["spine"]["explicit"]), (&json!(true), &json!(true))); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"easingType": "easeOut", "ramp": 40})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); +} + +#[test] +fn a_blend_without_a_spine_draws_its_steps_where_one_with_a_spine_does() { + let mut s = session(); + for orientation in ["page", "path"] { + let spined = make(&mut s, 0.0, json!({"orientation": orientation})); + let bare = make(&mut s, 0.0, json!({"orientation": orientation, "createSpine": "never"})); + let boxes = |g: NodeId| -> Vec<_> { vectorcraft_doc::live::expand_live(&node(&s, g)).iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(bare), boxes(spined), "{orientation}"); + assert!(boxes(bare).len() >= 3, "the three steps at least"); + } + // Easing stored on a blend without a spine (a hand-edited file) leaves its steps evenly spaced. + let bare = make(&mut s, 300.0, json!({"orientation": "path", "createSpine": "never"})); + let even = vectorcraft_doc::live::expand_live(&node(&s, bare)); + let mut eased = node(&s, bare); + if let NodeKind::Blend { spec, .. } = &mut eased.kind { + spec.ease = vectorcraft_doc::live::BlendEase { kind: vectorcraft_doc::live::BlendEaseType::In, ramp: 1.0 }; + } + let boxes = |v: &[Node]| -> Vec<_> { v.iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(&vectorcraft_doc::live::expand_live(&eased)), boxes(&even)); +} + +#[test] +fn whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one() { + let mut s = session(); + let bare = make(&mut s, 0.0, json!({"createSpine": "never"})); + let spined = make(&mut s, 50.0, json!({})); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + let saved = |g: NodeId| match &back.node(g).unwrap().kind { + NodeKind::Blend { spec, .. } => spec.no_spine, + _ => panic!("not a blend"), + }; + assert!(saved(bare) && !saved(spined)); + assert_eq!(serde_json::to_value(spec(&s, bare).1).unwrap()["noSpine"], json!(true)); + assert!(serde_json::to_value(spec(&s, spined).1).unwrap().get("noSpine").is_none(), "a spine is not written"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(!old.no_spine, "a blend saved before Create Spine has a spine"); + for odd in [json!("yes"), json!(1), json!(null)] { + let v: BlendSpec = serde_json::from_value(json!({"noSpine": odd.clone()})).unwrap_or_else(|e| panic!("{odd}: {e}")); + assert!(!v.no_spine, "{odd}"); + } +} diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 3ed51e3c38..0fc2bcb476 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2945,6 +2945,120 @@ fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() { assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue }); } +#[test] +fn live_paint_bucket_drag_fills_multiple_faces() { + let mut s = session(); + let g = live(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + let before = s.doc().unwrap().doc.clone(); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + // Skip the middle face between pointer events, as can happen in a fast drag. + s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + let gn = node(&s, g); + for x in [25.0, 75.0, 125.0] { + let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill, "dragged face at {x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "one Undo restores every face"); + s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "hover still highlights a face"); + assert_eq!(s.doc().unwrap().doc, before, "hover does not paint"); +} + +#[test] +fn live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel() { + for (deferred, cancel) in [(false, false), (false, true), (true, false)] { + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (50.25, 0.0), (50.25, 100.0))]; + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.prefs.real_time_drawing = !deferred; + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap(); + if cancel { + assert!(s.tool_busy()); + assert!(s.tool_claims_key(ToolKey::Escape, v)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert!(!s.tool_busy()); + assert!(!s.tool_claims_key(ToolKey::Escape, v)); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + if !cancel { + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + for x in [25.0, 50.125, 125.0] { + let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill, "deferred={deferred}, x={x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc == before, "deferred={deferred}, cancel={cancel}: original paths and selection art must be restored"); + } +} + +#[test] +fn live_paint_bucket_long_drag_keeps_late_turns_and_release_as_one_undo() { + for cancel in [false, true] { + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (100.0, 0.0), (100.0, 100.0))]; + s.execute("select.set", &json!({"ids": ids.map(|id| id.0)})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.prefs.real_time_drawing = false; + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + for i in 1..4096 { + s.pointer(&PointerEvent::new(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 50.0), v).unwrap(); + } + // Turn around the middle face after the chunk limit, then enter the right face on Up. + for (x, y) in [(25.0, 150.0), (125.0, 150.0)] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap(); + } + if cancel { + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + if !cancel { + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + for x in [25.0, 125.0] { + assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), s.paint.fill); + } + assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(75.0, 50.0)).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE)); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc == before, "long drag is one cancellable undo step"); + } +} + +#[test] +fn live_paint_bucket_drag_rejects_invalid_points_atomically() { + let mut s = session(); + let g = live(&mut s); + let before = s.doc().unwrap().doc.clone(); + for points in + [json!([]), json!([[26.0, 50.0]]), json!([[25.0, 50.0], [null, 0]]), json!([[25.0, 50.0], [1e30, 0]]), json!(vec![[25.0, 50.0]; 4097])] + { + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": points})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + } + for continuation in [json!(null), json!([[[26.0, 50.0], [125.0, 50.0]]]), json!([vec![[25.0, 50.0]; 4097]])] { + assert!( + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": [[25.0, 50.0]], "continuation": continuation})) + .is_err() + ); + assert_eq!(s.doc().unwrap().doc, before); + } +} + #[test] fn live_paint_bucket_tool_fills_face() { let mut s = session(); diff --git a/crates/engine/src/tests/gradpresets.rs b/crates/engine/src/tests/gradpresets.rs index 437f96d26c..807094a1bd 100644 --- a/crates/engine/src/tests/gradpresets.rs +++ b/crates/engine/src/tests/gradpresets.rs @@ -38,7 +38,10 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() { // Grouped by library, the gradient libraries first, then the swatch libraries holding gradients. let mut libraries: Vec<&str> = rows.iter().map(|r| r["library"].as_str().unwrap()).collect(); libraries.dedup(); - assert_eq!(libraries, ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Duotone Gradients", "Radial Glows", "Metallic Gradients"]); + assert_eq!( + libraries, + ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"] + ); let mut seen = std::collections::HashSet::new(); assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); let vignette = rows.iter().find(|r| r["name"] == "Vignette").unwrap(); @@ -65,6 +68,20 @@ fn applying_a_preset_edits_the_gradient_in_place_as_one_undo_step() { assert_eq!(paint(&s, id, false), before); } +#[test] +fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() { + let (mut s, id) = session_with(json!({"color": "#336699"})); + let mist = s.gradient_preset("Mist").unwrap(); + assert_eq!((mist.library, mist.gradient.method), (Some("Organic Fades"), GradientMethod::Perceptual)); + s.execute("gradient.presets.apply", &json!({"name": "Mist"})).unwrap(); + let g = paint(&s, id, false).gradient; + assert_eq!((g.kind, g.method, &g.stops), (GradientKind::Linear, GradientMethod::Perceptual, &mist.gradient.stops)); + assert_eq!(s.gradient_preset_name(&g).as_deref(), Some("Mist")); + let list = s.execute("gradient.presets.list", &json!({})).unwrap(); + let row = list["presets"].as_array().unwrap().iter().find(|r| r["name"] == "Mist").unwrap().clone(); + assert_eq!(row["gradient"]["method"], "perceptual"); +} + #[test] fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() { let (mut s, id) = session_with(json!({"color": "#336699"})); @@ -334,3 +351,37 @@ fn saved_presets_round_trip_with_the_preferences() { fresh.apply_prefs(back); assert!(fresh.gradient_preset("mine").is_some_and(|e| e.library.is_none())); } + +/// Preferences can hold saved presets named like a built-in preset (saved before it existed) or +/// like each other (edited by hand): applied, each gets a name of its own, any case. +#[test] +fn saved_presets_named_like_a_built_in_or_another_saved_one_get_a_free_name() { + let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}})); + let mist = s.gradient_preset("Mist").unwrap().gradient; + let mut own = paint(&s, id, false).gradient; + own.dither = true; + let preset = |name: &str| crate::cmd::GradientPreset { name: name.into(), gradient: own.clone() }; + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = + vec![preset("mist"), preset("Mine"), preset("MINE"), preset("Mist 2"), preset("Plain"), preset("PLAIN"), preset("plain")]; + s.apply_prefs(prefs); + let saved: Vec<&str> = s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(saved, ["mist 3", "Mine", "MINE 2", "Mist 2", "Plain", "PLAIN 2", "plain 3"]); + let list = s.execute("gradient.presets.list", &json!({})).unwrap(); + let mut seen = std::collections::HashSet::new(); + assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); + // The renamed one applies and deletes; the built-in Mist stays as it was. + s.execute("gradient.presets.apply", &json!({"name": "mist 3"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient, own); + assert_eq!(s.gradient_preset("Mist").unwrap().gradient, mist); + assert_eq!(s.execute("gradient.presets.delete", &json!({"name": "Mist 3"})).unwrap()["deleted"], "mist 3"); + assert!(s.execute("gradient.presets.delete", &json!({"name": "Mist"})).is_err(), "built in"); + assert_eq!(s.prefs.gradient_presets.len(), 6); + // Many presets with one name each get their own number. + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = (0..2000).map(|_| preset("Same")).collect(); + s.apply_prefs(prefs); + let mut seen = std::collections::HashSet::new(); + assert!(s.prefs.gradient_presets.iter().all(|p| seen.insert(p.name.to_lowercase()))); + assert_eq!(s.prefs.gradient_presets.last().map(|p| p.name.as_str()), Some("Same 2000")); +} diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index fccbe4a2d3..28cb0a1590 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1855,6 +1855,38 @@ fn convert_to_artboards_names_by_the_artboards_the_document_has() { assert_eq!(name_after(&|s| _ = s.execute("edit.undo", &json!({})).unwrap()), "Artboard 3"); } +/// Convert to Artboards on several objects gives each new artboard an id above the ids given out +/// before and the deleted artboard's, and names it one past the highest id the artboards have, +/// counting the artboards it has just added. With an artboard whose id is `u32::MAX` (a damaged +/// file), each new artboard takes the smallest free id and the name stays at `u32::MAX`. +#[test] +fn convert_to_artboards_gives_each_object_the_next_id_and_name() { + let convert = |s: &mut Session| { + let ids: Vec = (0..3).map(|i| rect(s, 3000.0 + 300.0 * f64::from(i), 0.0, 200.0, 100.0)).collect(); + sel(s, &ids); + assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 3); + let d = &s.doc().unwrap().doc; + d.artboards.iter().map(|a| (a.id, a.name.clone())).collect::>() + }; + let named = |pairs: &[(u32, &str)]| pairs.iter().map(|&(id, n)| (id, n.to_string())).collect::>(); + + // Artboards 1 and 2 are left after artboard 3 is deleted, so its id stays given out. + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + let ids: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + assert_eq!(ids, [1, 2]); + let got = convert(&mut s); + assert_eq!(got, named(&[(1, "Artboard 1"), (2, "Artboard 2"), (4, "Artboard 3"), (5, "Artboard 5"), (6, "Artboard 6")])); + + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[0].id = u32::MAX; + let got = convert(&mut s); + let top = format!("Artboard {}", u32::MAX); + assert_eq!(got, named(&[(u32::MAX, "Artboard 1"), (1, &top), (2, &top), (3, &top)])); +} + /// Rearrange All Artboards refuses `columns` that are not a whole number (a fraction, a negative number or a string), /// and the artboards stay where they were; 1.0, as the Rearrange All Artboards dialog sends a count, is one column. #[test] diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index d21754e363..4d225e4bdc 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1820,11 +1820,11 @@ fn a_grant_that_cannot_be_removed_and_has_no_stored_module_stops_counting() { run(&mut s, "plugin.remove", json!({"id": id})); } -/// A grant that a load of the plug-in store should forget and can't stays out of the approvals for -/// the session: both when `grants.json` can't be rewritten and when it can't be read at the load. -/// A grant recorded later counts again, and another store starts with nothing ignored. +/// A grant that a load of the plug-in store should forget and can't stays out of the approvals in +/// the process: both when `grants.json` can't be rewritten and when it can't be read at the load. +/// A grant recorded later counts again, and another store's grant for the id counts. #[test] -fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { +fn a_grant_the_load_cannot_remove_stops_counting_in_the_process() { use vectorcraft_plugins::permissions::sha256_hex; let folder = crate::tests_links::Folder::new("plugin-store-load-forget"); let elsewhere = crate::tests_links::Folder::new("plugin-store-load-forget-other"); @@ -1856,8 +1856,12 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { next.attach_plugin_store(elsewhere.0.clone()); assert_eq!(run(&mut next, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + // A load that can rewrite the file forgets the grant, and the store has nothing ignored again. + std::fs::write(&grants, &recorded).unwrap(); + next.attach_plugin_store(folder.0.clone()); + assert!(!grants.exists(), "the load removes the grant"); + // A directory at the file's path gives a read error on every platform. - std::fs::remove_file(&grants).unwrap(); std::fs::create_dir(&grants).unwrap(); vectorcraft_plugins::registry::remove(id); let mut third = session(); @@ -1883,6 +1887,180 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { run(&mut third, "plugin.remove", json!({"id": id})); } +/// A grant one session stops counting stops counting for every session in the process attached to +/// the same folder, by any path to it: one attached before, one attached after with no stored module +/// to try the removal for, and one that comes back from another folder. A successful write of the +/// grant in any of them makes it count in all of them again. +#[test] +fn an_ignored_stored_grant_is_shared_by_sessions_on_the_same_folder() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-shared-ignore"); + let elsewhere = crate::tests_links::Folder::new("plugin-store-shared-ignore-other"); + let id = "test.engine.permissions.sharedignore"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let needs_approval = json!({"dataBase64": approved_data}); + let grants = folder.0.join("grants.json"); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + let mut first = session(); + first.attach_plugin_store(folder.0.clone()); + let inside = folder.0.join("inside"); + std::fs::create_dir(&inside).unwrap(); + let mut other = session(); + other.attach_plugin_store(inside.join("..")); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + // `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read. + let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes(); + std::fs::write(&grants, &full).unwrap(); + run(&mut first, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)})); + let file = first.stored_plugin_file(id).unwrap(); + assert!(!std::path::Path::new(&file).exists(), "the store keeps no module"); + assert_eq!(std::fs::read(&grants).unwrap(), full); + + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + let refused = other.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string(); + assert!(refused.contains("doc.read"), "{refused}"); + std::fs::write(&file, &approved).unwrap(); + vectorcraft_plugins::registry::remove(id); + other.load_stored_plugins(); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "the formerly approved module isn't loaded"); + std::fs::remove_file(&file).unwrap(); + + let mut later = session(); + later.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut later, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "a session attached later doesn't count it"); + + std::fs::write(elsewhere.0.join("grants.json"), &recorded).unwrap(); + other.attach_plugin_store(elsewhere.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + other.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "back on the folder, it doesn't"); + + std::fs::write(&grants, &recorded).unwrap(); + assert!(later.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + for s in [&mut first, &mut other, &mut later] { + assert_eq!(run(s, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + } + std::fs::remove_file(&grants).unwrap(); +} + +/// A plug-in folder that doesn't exist yet gets the key it gets once it is made, and every path to a +/// folder gets the same key. +#[test] +fn a_plugin_folder_has_one_key_for_every_path_to_it() { + use crate::store::plugin_store_key; + let folder = crate::tests_links::Folder::new("plugin-store-key"); + let dir = folder.0.join("Plug-ins"); + let before = plugin_store_key(&dir); + assert_eq!(plugin_store_key(&dir.join("sub").join("..")), before); + assert_eq!(plugin_store_key(&folder.0.join(".").join("Plug-ins")), before); + std::fs::create_dir(&dir).unwrap(); + assert_eq!(plugin_store_key(&dir), before, "the key stays when the folder is made"); + assert_eq!(plugin_store_key(&dir.join(".")), before); + #[cfg(windows)] + { + let plain = std::path::absolute(&dir).unwrap(); + let verbatim = std::path::PathBuf::from(format!(r"\\?\{}", plain.display())); + assert_eq!(plugin_store_key(&verbatim), before, "a verbatim path names the same folder"); + } +} + +/// Sessions attached to a plug-in folder before it exists, by paths that only name the same folder +/// once it is made, share the grants the process doesn't count for it. A grant that one session +/// writes while another fails to remove it keeps counting. +#[test] +fn sessions_attached_before_the_folder_exists_share_its_ignored_grants() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-late-folder"); + let id = "test.engine.permissions.latefolder"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + std::fs::create_dir_all(folder.0.join("a").join("b")).unwrap(); + let dir = folder.0.join("a").join("Plug-ins"); + // On disk `link/..` is `a`, so `link/../Plug-ins` is the folder; by name it is a `Plug-ins` beside `a`. + // On Windows the folder is named in other letter case. + #[cfg(unix)] + let alias = { + std::os::unix::fs::symlink(folder.0.join("a").join("b"), folder.0.join("link")).unwrap(); + folder.0.join("link").join("..").join("Plug-ins") + }; + #[cfg(windows)] + let alias = folder.0.join("A").join("PLUG-INS"); + #[cfg(not(any(unix, windows)))] + let alias = dir.clone(); + let mut first = session(); + first.attach_plugin_store(dir.clone()); + let mut other = session(); + other.attach_plugin_store(alias); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + first.ignore_stored_grant(id, first.grant_writes()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + assert!(other.store_grant(id, Some(serde_json::from_value(entry.clone()).unwrap())), "the file can be written"); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + + // A write that fails in one session while another writes the grant for the id leaves the grant + // counting. + let before = first.grant_writes(); + assert!(other.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the other session writes the grant"); + first.ignore_stored_grant(id, before); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval)["needsApproval"], false, "the written grant counts"); + std::fs::remove_file(dir.join("grants.json")).unwrap(); +} + +/// Grant writes that change nothing, each for another id, don't grow what the process keeps past its +/// limit. +#[test] +fn the_process_keeps_a_bounded_number_of_grant_writes() { + let folder = crate::tests_links::Folder::new("plugin-store-writes-kept"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + for n in 0..300 { + assert!(s.store_grant(&format!("test.engine.permissions.unknown{n}"), None), "nothing to remove is a success"); + } + assert!(Session::grant_writes_kept() <= 256, "{}", Session::grant_writes_kept()); +} + +/// A session attached by a link to a plug-in folder matches the grants the process doesn't count +/// with the folder the link leads to now, not the one it led to when the session was attached. +#[cfg(unix)] +#[test] +fn a_linked_plugin_folder_uses_the_ignored_grants_of_where_the_link_leads() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-relinked"); + let id = "test.engine.permissions.relinked"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + let (old, new, link) = (folder.0.join("old"), folder.0.join("new"), folder.0.join("Plug-ins")); + for dir in [&old, &new] { + std::fs::create_dir(dir).unwrap(); + std::fs::write(dir.join("grants.json"), &recorded).unwrap(); + } + std::os::unix::fs::symlink(&old, &link).unwrap(); + let mut linked = session(); + linked.attach_plugin_store(link.clone()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&new, &link).unwrap(); + let mut direct = session(); + direct.attach_plugin_store(new.clone()); + direct.ignore_stored_grant(id, direct.grant_writes()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the link leads to the ignored grant"); + assert!(linked.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + assert_eq!(run(&mut direct, "plugin.inspect", needs_approval)["needsApproval"], false, "it counts again"); +} + /// A grants file that can't be opened or read may name the old module once it reads again, so /// neither a module listing no permissions nor `plugin.remove` gets past it. A directory at the /// file's path gives a read error on every platform. @@ -2123,6 +2301,35 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// When the last grant can't be removed because `grants.json` can't be deleted, a replacing module +/// that asks for no permissions doesn't stay in the store: the store gets back the module the grant +/// still covers, and the grant is left as it was. +#[test] +fn a_grants_file_whose_deletion_fails_keeps_the_stored_module() { + let folder = crate::tests_links::Folder::new("plugin-store-undeletable-grants"); + let id = "test.engine.permissions.undeletablegrants"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let grants = folder.0.join("grants.json"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); + let file = s.stored_plugin_file(id).unwrap(); + let recorded = std::fs::read(&grants).unwrap(); + + crate::store::GRANTS_DELETE_FAILS.set(true); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); + crate::store::GRANTS_DELETE_FAILS.set(false); + assert_eq!(std::fs::read(&file).unwrap(), approved, "the store gets the module the grant covers back"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant stays as it was"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], false, "the grant still covers the module"); + + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!grants.exists(), "a removal that can delete the file forgets the grant"); +} + /// A plug-in folder that leaves a module listing no permissions under an id forgets the process's /// grant for it, unless a later file in the folder leaves a granted module there. A grant kept in /// the plug-in store stays, with the stored module it covers. diff --git a/crates/engine/src/tests/prefs_roots.rs b/crates/engine/src/tests/prefs_roots.rs new file mode 100644 index 0000000000..b9818949f1 --- /dev/null +++ b/crates/engine/src/tests/prefs_roots.rs @@ -0,0 +1,107 @@ +//! The preferences commands under automation roots: a confined request sees a folder preference and +//! an export preset's folder only inside its read roots. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::cmd::fileio::ScreenExportPreset; +use crate::file_access::{AutomationRoots, confine}; +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn text(p: &Path) -> String { + p.to_string_lossy().to_string() +} + +/// A fresh scratch folder for one test, with the folders `in` and `out`. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-prefs-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for sub in ["in", "out"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + } + dir +} + +/// The folder preferences that name the folder outside the read root. +const OUTSIDE: [&str; 3] = ["pluginsFolder", "scratchPrimary", "templatesFolder"]; + +/// A session whose folder preferences name `inside` (fonts, and the Data Recovery folder with +/// spaces around it) and `outside` (the [`OUTSIDE`] ones, the Templates folder with spaces around +/// it), with a scratch disk choice and an export preset kept with each folder. +fn session(inside: &str, outside: &str) -> Session { + let mut s = Session::new(); + s.prefs.fonts_folder = inside.to_string(); + s.prefs.recovery_folder = format!(" {inside} "); + s.prefs.plugins_folder = outside.to_string(); + s.prefs.scratch_primary = outside.to_string(); + s.prefs.scratch_secondary = "None".to_string(); + s.prefs.templates_folder = format!(" {outside} "); + for (name, folder) in [("Inside", inside), ("Outside", outside)] { + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.prefs.screen_export_presets.push(ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() }); + } + s +} + +/// Does a string anywhere in `v` hold `path`? +fn holds(v: &Value, path: &str) -> bool { + v.to_string().contains(&path.replace('\\', "\\\\")) +} + +#[test] +fn prefs_commands_leave_out_folders_outside_the_read_root() { + let base = folder("get"); + let (inside, outside) = (text(&base.join("in").join("fonts")), text(&base.join("out"))); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let mut s = session(&inside, &outside); + + let all = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({}))); + let prefs = &all["prefs"]; + assert_eq!(prefs["fontsFolder"], json!(inside), "a folder inside the root is shown"); + assert_eq!(prefs["recoveryFolder"], json!(format!(" {inside} ")), "checked without the spaces, shown as kept"); + assert_eq!(prefs["scratchSecondary"], json!("None"), "a scratch disk choice is shown"); + for key in OUTSIDE { + assert_eq!(prefs[key], Value::Null, "{key}: {all}"); + } + let presets = prefs["screenExportPresets"].as_array().unwrap(); + assert_eq!(presets[0]["settings"]["folder"], json!(inside)); + assert_eq!(presets[1]["name"], json!("Outside"), "the preset stays"); + assert!(presets[1]["settings"].get("folder").is_none(), "{all}"); + assert!(!holds(&all, &outside), "{all}"); + + for key in OUTSIDE { + let one = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": key}))); + assert_eq!(one, Value::Null, "{key}"); + } + assert_eq!(confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": "fontsFolder"}))), json!(inside)); + + let list = confine(roots.as_ref(), || run(&mut s, "prefs.list", json!({}))); + assert!(!holds(&list, &outside), "{list}"); + let row = |key: &str| list.as_array().unwrap().iter().find(|r| r["key"] == key).cloned().unwrap(); + assert_eq!(row("fontsFolder")["value"], json!(inside)); + assert_eq!(row("pluginsFolder")["value"], Value::Null); + assert_eq!(row("pluginsFolder")["kind"], json!("text"), "the row stays"); + + // A request that sets another preference, or resets a category without folders. + let set = confine(roots.as_ref(), || run(&mut s, "prefs.set", json!({"values": {"keyboardIncrement": 2, "pluginsFolder": outside}}))); + assert_eq!(set, json!({"keyboardIncrement": 2.0, "pluginsFolder": null}), "a folder kept as it was is left out too"); + let reset = confine(roots.as_ref(), || run(&mut s, "prefs.reset", json!({"category": "General"}))); + assert!(!holds(&reset, &outside), "{reset}"); + assert_eq!(reset["prefs"]["fontsFolder"], json!(inside)); + + // Unconfined and with only a write root. + let all = run(&mut s, "prefs.get", json!({})); + assert_eq!(all["prefs"]["pluginsFolder"], json!(outside), "unconfined, the folder is shown"); + assert_eq!(all["prefs"]["screenExportPresets"][1]["settings"]["folder"], json!(outside)); + let write_only = AutomationRoots::new(&[], &[&base.join("in")]).unwrap(); + let all = confine(write_only.as_ref(), || run(&mut s, "prefs.get", json!({}))); + assert_eq!(all["prefs"]["fontsFolder"], Value::Null, "without a read root no folder is shown"); + assert_eq!(all["prefs"]["recoveryFolder"], Value::Null); + assert_eq!(s.prefs.plugins_folder, outside, "the preferences are unchanged"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 4994a90728..f0b6a0708c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -682,6 +682,343 @@ fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() { let _ = std::fs::remove_dir_all(&dir); } +/// A session keeping its copies in folder `dir`, with one modified document whose copy is written. +fn saved_in(dir: &std::path::Path) -> (Session, String) { + let mut s = Session::new(); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + let area = s.recovery.own_area().unwrap().to_string(); + (s, area) +} + +/// A new app over folder `dir`, with nothing open. +#[cfg(unix)] +fn other_app(dir: &std::path::Path) -> Session { + let mut s = Session::new(); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + s +} + +/// The area's lock file was removed while its app runs: the next recovery save locks the area +/// again, so another app neither offers nor discards its copy. +#[cfg(unix)] +#[test] +fn a_recovery_save_locks_the_area_again_when_its_lock_file_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-relock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(a.recovery.own_area(), Some(area.as_str())); + let mut b = other_app(&dir); + assert!(offered(&mut b).is_empty()); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The whole area was removed while its app runs: the next recovery save makes it again, locked, +/// with the copy written again. +#[cfg(unix)] +#[test] +fn a_recovery_save_makes_the_area_again_when_it_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-remade-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_dir_all(dir.join(&area)).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another app locked the area after its lock file was removed: the next heartbeat takes a new +/// area, and the document is to be copied again there. Its copy in the old area stays until then. +#[cfg(unix)] +#[test] +fn an_area_another_app_locked_meanwhile_is_left_for_a_new_one() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-moved-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let store = FolderStore::new(&dir); + let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") }; + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert!(a.doc().unwrap().recovery.is_none()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file(), "kept until the new copy is written"); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists(), "the copy left there goes"); + assert!(matches!(store.lock(&mine), Ok(Lock::Busy))); + drop(other); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy written in the background into the area the session left meanwhile is not the +/// document's copy: it stays only until the document is copied again in the new area. +#[cfg(unix)] +#[test] +fn a_copy_written_into_an_area_left_meanwhile_goes_once_copied_again() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-inflight-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let store: Arc = Arc::new(FolderStore::new(&dir)); + let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap(); + assert_eq!(jobs.len(), 1); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + for j in jobs { + j.write().unwrap(); + j.finish(&mut a); + } + assert!(a.doc().unwrap().recovery.is_none()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid], "the next heartbeat has it copied again"); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}"); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists()); + assert!(cmd::recovery::heartbeat(&mut a).is_empty()); + drop(other); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another app locked the area meanwhile and the document is unchanged since its copy: the next +/// recovery save still writes the copy, in the new area. +#[cfg(unix)] +#[test] +fn a_recovery_save_copies_unchanged_documents_into_the_new_area() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-unchanged-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file()); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists()); + drop(other); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy left in an area the session had to leave goes at once when its document is saved, +/// reverted or closed, and every one goes when the app quits with nothing unsaved. +#[cfg(unix)] +#[test] +fn copies_left_behind_go_when_their_document_is_saved_reverted_or_closed() { + use cmd::recovery::Lock; + let base = std::env::temp_dir().join(format!("vc-recovery-leftgo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("recovery"); + let path = base.join("doc.vectorcraft").to_string_lossy().to_string(); + let left = |area: &str| std::fs::read_dir(dir.join(area)).unwrap().flatten().any(|e| e.path().extension().is_some_and(|x| x == "vectorcraft")); + for how in ["save", "revert", "close", "quit"] { + let (mut a, _) = saved_in(&dir); + a.execute("document.save", &json!({"path": path})).unwrap(); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + assert!(left(&area), "{how}"); + match how { + "save" => a.execute("document.save", &json!({"path": path})).map(drop).unwrap(), + "revert" => a.execute("file.revert", &json!({})).map(drop).unwrap(), + "close" => a.execute("file.close", &json!({})).map(drop).unwrap(), + _ => cmd::recovery::forget_all(&mut a), + } + assert!(!left(&area), "{how}"); + drop(other); + drop(a); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// The lock file was removed: the next heartbeat locks the area again, before any recovery save. +#[cfg(unix)] +#[test] +fn a_heartbeat_locks_the_area_again_when_its_lock_file_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-beatlock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "the copy is still there"); + assert_eq!(a.recovery.own_area(), Some(area.as_str())); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The lock file was removed and can't be made again (the area folder can't be written to now): +/// the session takes a new area and writes its copy there. +#[cfg(unix)] +#[test] +fn an_area_whose_lock_file_cannot_be_made_again_is_left_for_a_new_one() { + use std::os::unix::fs::PermissionsExt as _; + + use cmd::recovery::Lock; + // Permissions don't stop the superuser. + if rustix::process::geteuid().is_root() { + return; + } + let dir = std::env::temp_dir().join(format!("vc-recovery-readonly-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(matches!(FolderStore::new(&dir).lock(&mine), Ok(Lock::Busy))); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap(); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy left behind that can't be removed when its document is saved (its area can't be written +/// to now) stays the session's own: it isn't offered here, and a later heartbeat removes it. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_that_cannot_be_removed_yet_stays_the_sessions_own() { + use std::os::unix::fs::PermissionsExt as _; + + use cmd::recovery::Lock; + // Permissions don't stop the superuser. + if rustix::process::geteuid().is_root() { + return; + } + let base = std::env::temp_dir().join(format!("vc-recovery-leftstuck-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("recovery"); + let path = base.join("doc.vectorcraft").to_string_lossy().to_string(); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap(); + a.execute("document.save", &json!({"path": path})).unwrap(); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap(); + let copy = dir.join(&area).join("Untitled-1-1.vectorcraft"); + assert!(copy.is_file()); + drop(other); + assert!(offered(&mut a).is_empty()); + cmd::recovery::heartbeat(&mut a); + assert!(!copy.exists()); + drop(a); + let _ = std::fs::remove_dir_all(&base); +} + +/// A copy left in an area the session had to leave is still the session's own after the app that +/// locked that area is gone: the session neither offers nor discards it. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_is_not_offered_by_its_own_app() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftmine-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + drop(other); + assert!(offered(&mut a).is_empty()); + let r = a.execute("file.recovery.discard", &json!({})).unwrap(); + assert!(r["discarded"].as_array().unwrap().is_empty(), "{r}"); + assert!(a.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A complex document (Data Recovery off for those) gets no new copy after the session left its +/// area: it keeps the copy it left there, and the heartbeat asks for it only once. +#[cfg(unix)] +#[test] +fn a_complex_documents_copy_left_behind_is_asked_for_once() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftcomplex-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut a = other_app(&dir); + let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0); + let layer = d.layers[0].id; + for i in 0..cmd::recovery::COMPLEX_OBJECTS { + let id = d.alloc_id(); + let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0); + let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()); + d.insert(Some(layer), i, n).unwrap(); + } + a.add_document(d, None); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let copy = r["saved"][0]["file"].as_str().unwrap().to_string(); + let area = a.recovery.own_area().unwrap().to_string(); + a.prefs.recovery_off_for_complex = true; + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert!(r["saved"].as_array().unwrap().is_empty(), "{r}"); + assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "asked for once"); + assert!(dir.join(format!("{copy}.vectorcraft")).is_file(), "its copy stays"); + drop(other); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The preference named another folder while a copy was written in the background: the copy goes +/// from the folder it was written to, and the document keeps its copy in the new folder. +#[test] +fn a_copy_finished_after_the_folder_changed_goes_from_its_own_folder() { + let base = std::env::temp_dir().join(format!("vc-recovery-refolder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (old, new) = (base.join("old"), base.join("new")); + let (mut a, area) = saved_in(&old); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let store: Arc = Arc::new(FolderStore::new(&old)); + let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap(); + a.prefs.recovery_folder = new.to_string_lossy().to_string(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.doc().unwrap().recovery.as_ref().unwrap().file.clone(); + for j in jobs { + j.write().unwrap(); + j.finish(&mut a); + } + assert!(!old.join(&area).join("Untitled-1-1.vectorcraft").exists()); + assert_eq!(a.doc().unwrap().recovery.as_ref().unwrap().file, mine); + assert!(new.join(format!("{mine}.vectorcraft")).is_file()); + drop(a); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn saving_reverting_or_closing_removes_the_copy() { let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index d92365a356..22a0239fb2 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -425,8 +425,16 @@ fn transparency_grid_round_trips_without_dirtying() { let _ = std::fs::remove_dir_all(d); } +/// The `%%BoundingBox` of an EPS file's PostScript. +fn bounding_box(eps: &[u8]) -> [i64; 4] { + let ps = std::str::from_utf8(vectorcraft_eps::sections(eps).unwrap().0).unwrap(); + let line = ps.lines().find_map(|l| l.strip_prefix("%%BoundingBox:")).expect("a bounding box"); + let v: Vec = line.split_whitespace().map(|n| n.parse().unwrap()).collect(); + [v[0], v[1], v[2], v[3]] +} + #[test] -fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { +fn opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page() { let d = dir("eps-original-format"); let eps_path = path(&d, "Artwork.eps"); // Original, contributor-authored PostScript: the artwork occupies only part of the page. @@ -440,11 +448,16 @@ fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { assert_eq!((board.width(), board.height()), (200.0, 100.0)); rect(&mut s); let edited_layers = s.doc().unwrap().doc.layers.clone(); - let r = s.execute("document.save", &json!({"options": {"previewFormat": "none", "thumbnails": false}})).unwrap(); + // Without Use Artboards the bounding box fits the art, as Export draws it; the editing data + // keeps the page. + let quiet = json!({"previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"options": quiet})).unwrap(); assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps"))); let bytes = std::fs::read(&eps_path).unwrap(); assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); - assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); + let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); + assert_eq!(bounding_box(&bytes), bounding_box(&exported)); + assert_ne!(bounding_box(&bytes), [0, 0, 200, 100], "the art does not fill the page"); let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(editing.artboards[0].rect, board); assert_eq!(editing.layers, edited_layers); @@ -453,26 +466,101 @@ fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { assert_eq!(opened["restored"], true); assert_eq!(s.doc().unwrap().doc.layers, edited_layers); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); + // Use Artboards keeps the opened file's page. + rect(&mut s); + s.execute("document.save", &json!({"options": {"useArtboards": true, "previewFormat": "none", "thumbnails": false}})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); + // Save remembers it. rect(&mut s); s.execute("document.save", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); std::fs::remove_dir_all(d).unwrap(); } #[test] -fn eps_save_keeps_all_editing_artboards_while_printing_one_page() { +fn eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": -50, "y": 20, "width": 300, "height": 40})).unwrap(); + let quiet = json!({"previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"format": "eps", "options": quiet})).unwrap(); + let [x0, y0, x1, y1] = bounding_box(&b64(&r)); + assert!(x0 <= -50 && x1 >= 250 && y0 >= 0 && y1 <= 100, "{:?}", [x0, y0, x1, y1]); + for (use_artboards, expected) in [(false, [x0, y0, x1, y1]), (true, [0, 0, 200, 100])] { + let options = json!({"useArtboards": use_artboards, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap(); + assert_eq!(bounding_box(&b64(&r)), expected, "useArtboards: {use_artboards}"); + } +} + +#[test] +fn eps_save_keeps_all_editing_artboards_in_the_file_of_one_page() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap(); let bytes = b64(&r); + assert!(r.get("files").is_none(), "{r}"); + let board = s.doc().unwrap().doc.artboards[1].rect; + let [x0, y0, x1, y1] = bounding_box(&bytes); + assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.width(), board.height())); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards.len(), 2); assert_eq!(native.layers, s.doc().unwrap().doc.layers); - assert!(s.execute("document.save", &json!({"format": "eps", "range": "all"})).is_err(), "EPS is one printed page"); - for options in [json!({"useArtboards": false}), json!({"selectedOnly": true})] { - assert!(s.execute("document.save", &json!({"format": "eps", "options": options})).is_err(), "cropping is Export's job"); + assert!(s.execute("document.save", &json!({"format": "eps", "options": {"selectedOnly": true}})).is_err(), "selected-only is Export's job"); +} + +#[test] +fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() { + let d = dir("eps-named-artboard"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "artboard": 1, "previewFormat": "none", "thumbnails": false}); + s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let o = s.execute("file.formatOptions", &json!({})).unwrap(); + assert_eq!(o["options"]["useArtboards"]["value"], true); + assert_eq!(o["options"]["range"]["value"], "2", "the saved artboard, not All"); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { + let d = dir("eps-artboards"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let files: Vec = r["files"].as_array().expect("files").iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 3, "{r}"); + assert_eq!(files[0], eps_path, "the file the document becomes comes first"); + assert!(files[1..].iter().all(|f| f.starts_with(&path(&d, "Art_")) && f.ends_with(".eps")), "{files:?}"); + // The central file: the visible art and the whole document. + let central = std::fs::read(&eps_path).unwrap(); + let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); + assert_eq!(bounding_box(¢ral), bounding_box(&exported)); + assert_eq!(vectorcraft_format::load(&vectorcraft_eps::native(¢ral).unwrap()).unwrap().artboards.len(), 2); + // Each artboard's file: its artboard as the page, holding that artboard. + let boards = s.doc().unwrap().doc.artboards.clone(); + for (f, board) in files[1..].iter().zip(&boards) { + let bytes = std::fs::read(f).unwrap(); + let [x0, y0, x1, y1] = bounding_box(&bytes); + assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.rect.width(), board.rect.height()), "{f}"); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards.iter().map(|a| a.rect).collect::>(), [board.rect], "{f}"); } + let st = s.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(eps_path.as_str()), false)); + // With no visible art the central file is still written, on the first artboard. + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + std::fs::remove_file(&eps_path).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); + std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -550,7 +638,7 @@ fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap(); rect(&mut s); // Explicit colour preview remains a colour preview; Save bounds its resolution, not the art. - let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap(); + let r = s.execute("document.save", &json!({"format": "eps", "useArtboards": true, "previewFormat": "tiffColor"})).unwrap(); let bytes = b64(&r); let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); let tiff = tiff.unwrap(); diff --git a/crates/engine/src/tests/script_dialogs.rs b/crates/engine/src/tests/script_dialogs.rs index 7572ca52f0..f955a6fdb3 100644 --- a/crates/engine/src/tests/script_dialogs.rs +++ b/crates/engine/src/tests/script_dialogs.rs @@ -217,6 +217,26 @@ fn dialog_loop_stops_or_exhausts() { assert_eq!(art(&s), before); } +#[test] +fn a_run_waiting_at_a_dialog_stops_at_the_next_slice_once_its_work_is_canceled() { + let mut s = one_doc(); + let before = art(&s); + let token = crate::cancel::CancelToken::new(); + s.script_frames = true; + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); alert('x'); vc.log('answered');"; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true); + assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none()); + assert!(s.script_dialog().is_some()); + token.cancel(); + let ended = crate::cancel::scope(&token, None, || s.script_pump()); + assert!(ended.expect("the run goes on waiting for an answer").is_err()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{report}"); + assert_eq!(report["dialogs"][0]["answer"], "stop"); + assert_eq!(s.script_dialog(), None); + assert_eq!(art(&s), before); +} + #[test] fn dialog_text_limits_and_controls() { let mut s = one_doc(); diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index d5b9624500..57ff9b15cd 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -5,6 +5,7 @@ use std::time::Duration; use serde_json::{Value, json}; +use crate::cancel::{self, CancelToken}; use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK}; use crate::*; @@ -204,6 +205,36 @@ fn a_sleeping_run_stops_when_asked() { TEST_ELAPSED.set(None); } +#[test] +fn a_sleeping_run_stops_at_the_next_slice_once_its_work_is_canceled() { + for (asleep_until, when) in + [(None, "before its first slice"), (Some(Duration::ZERO), "while it sleeps"), (Some(Duration::from_secs(1)), "at its wake time")] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = (format!("{:?}", s.documents()[0].doc.layers), s.documents()[0].history.undo.len()); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 5, height: 5}); $.sleep(1000); vc.log('woke'); \ + vc.run('shape.rectangle', {x: 9, y: 0, width: 5, height: 5});"; + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + let token = CancelToken::new(); + if let Some(at) = asleep_until { + assert!(cancel::scope(&token, None, || s.script_pump()).is_none(), "{when}"); + assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1)), "{when}"); + TEST_ELAPSED.set(Some(at)); + } + token.cancel(); + let ended = cancel::scope(&token, None, || s.script_pump()); + TEST_ELAPSED.set(None); + assert!(ended.unwrap_or_else(|| panic!("{when}: the run goes on waiting")).is_err(), "{when}"); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{when}: {report}"); + assert!(!s.script_running(), "{when}"); + assert_eq!((format!("{:?}", s.documents()[0].doc.layers), s.documents()[0].history.undo.len()), before, "{when}"); + } +} + #[test] fn sleep_takes_milliseconds_from_0_to_1000() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 3359f272f9..417798a862 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -845,6 +845,30 @@ fn a_person_can_let_a_growing_run_go_on_or_stop_it() { assert_eq!(state(&s), before); } +#[test] +fn a_run_waiting_to_grow_stops_at_the_next_slice_once_its_work_is_canceled() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let mut s = two_docs(); + let before = state(&s); + s.script_frames = true; + run(&mut s, "for (var i = 0; i < 25; i++) vc.run('shape.rectangle', {x:i,y:1,width:1,height:1});").unwrap(); + let token = crate::cancel::CancelToken::new(); + for _ in 0..1_000 { + if s.script_waiting() { + break; + } + assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none()); + } + assert!(s.script_waiting()); + token.cancel(); + let ended = crate::cancel::scope(&token, None, || s.script_pump()); + TEST_GROWTH.with(|g| g.set(None)); + assert!(ended.expect("the run goes on waiting for Continue").is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "stopped"); + assert!(!s.script_running()); + assert_eq!(state(&s), before); +} + #[test] fn a_file_saved_before_a_failure_stays_written_and_is_named() { let path = std::env::temp_dir().join(format!("vc-script-save-{}.svg", std::process::id())); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 3ecb5e68e1..55ab34564a 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -638,6 +638,39 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } +#[test] +fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() { + let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap(); + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode})); + let list = run(&mut s, "swatch.library.list", json!({})); + let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "organic-fades").unwrap().clone(); + assert_eq!( + (row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), + (Some("Organic Fades"), Some("gradients"), Some(lib.len() as u64)) + ); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let r = run(&mut s, "swatch.library.add", json!({"library": "organic-fades", "names": ["Petal", "Dew"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Petal", "Dew"]), json!("Petal")), "{mode}"); + for name in ["Petal", "Dew"] { + // The swatch comes as the library has it: stops (colors as given, opacities, midpoints) and method. + let Paint::Gradient(added) = &doc(&s).swatch(name).unwrap().paint else { panic!("{name} is a gradient") }; + let Paint::Gradient(want) = &lib.swatch(name).unwrap().paint else { panic!("{name} is a gradient") }; + assert_eq!(added.gradient, want.gradient, "{mode} {name}"); + assert_eq!(added.gradient.method, vectorcraft_color::GradientMethod::Perceptual); + assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode} {name}: the stops stay RGB"); + assert!(added.gradient.stops.iter().any(|st| st.opacity < 1.0 && (st.midpoint - 0.5).abs() > 0.02), "{mode} {name}"); + } + // The fill takes the fade's method, opacities and midpoints. + let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + let (Paint::Gradient(fill), Paint::Gradient(want)) = (fill, &lib.swatch("Petal").unwrap().paint) else { panic!("a gradient fill") }; + assert_eq!(fill.gradient.method, want.gradient.method, "{mode}"); + let shape = |g: &vectorcraft_color::Gradient| g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect::>(); + assert_eq!(shape(&fill.gradient), shape(&want.gradient), "{mode}"); + } +} + #[test] fn pattern_libraries_bring_their_patterns_and_add_each_once() { let mut s = session(); diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs index f12f0d1962..a8961f3c46 100644 --- a/crates/engine/src/tests/turn_direction.rs +++ b/crates/engine/src/tests/turn_direction.rs @@ -114,3 +114,12 @@ fn keys_named(doc: &str) -> Vec { } found.into_iter().map(|(k, _)| k).collect() } + +/// Turn notes the key walk above doesn't reach: a numeric reflection axis and the Scatter brush's +/// `rotation [a, b]` range, whose head isn't a plain key. +#[test] +fn turns_outside_plain_keys_say_which_way() { + let doc = |id: &str| command_specs().iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default(); + assert!(doc("object.reflect").contains("|deg (the mirror line, counter-clockwise from horizontal"), "object.reflect's numeric axis"); + assert!(doc("brush.options").contains("rotation [a, b] −180..180° (counter-clockwise)"), "the Scatter brush's rotation"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 947cf559d0..b3779d8108 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -674,6 +674,8 @@ fn structured_junk() { ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "createSpine": ["never"], "easingType": "easeIn"})), + ("object.blend.createSpine", json!({"mode": 1e308})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index 5a4363108a..8d9ffc06c0 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -187,7 +187,8 @@ pub enum Expr { qualifier: Option, name: XmlNamePart, }, - /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope + /// that holds it, and also an assignment, update or `delete` target. XmlScopeName(ExprId), /// `object..name`. XmlDescendants { diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index f7c21a8d8e..301b1235a4 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -466,6 +466,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe .chain([parts.len().saturating_sub(1), HEAD.len() + MID.len() + TAIL.len(), body.len()]) .fold(0usize, usize::saturating_add); interp.check_source_units(total).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.prepare_source(total)?; let mut units = Vec::with_capacity(total); units.extend(HEAD.encode_utf16()); for (i, p) in parts.iter().enumerate() { @@ -554,6 +555,7 @@ fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult]) -> Result { Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), - Op::ForEachValue => e(2, 2), + Op::XmlScopeRef => e(1, 2), + Op::ForEachValue(n) => e(n + 2, n + 3), Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 071e99a60e..3023e3b127 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -497,16 +497,21 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); - if each { - self.emit(Op::ForEachValue); - } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); + if each { + self.emit(Op::ForEachValue(0)); + } self.emit(Op::SetVar(n)); } + ForInTarget::Expr(e) if each => self.store_value_to(*e)?, ForInTarget::Expr(e) => self.store_key_to(*e)?, } + if each { + // The value; the name under it goes next. + self.emit(Op::Pop); + } self.emit(Op::Pop); self.loop_body(body, labels, brk, head)?; self.jump(Op::Jump, head); @@ -538,6 +543,11 @@ impl<'a> FnCompiler<'a> { self.emit(Op::Rot3); self.emit(Op::SetIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::Rot3); + self.emit(Op::SetIndex); + } _ => { self.expr(target)?; self.emit(Op::Pop); @@ -547,6 +557,46 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Assigns the value under the name on top of the stack to a `for each` target expression, + /// leaving the name and then the value on top. The target is evaluated before the value is + /// read (ECMA-357 section 12.3). + fn store_value_to(&mut self, target: ExprId) -> CResult<()> { + let target = self.ast.unparenthesized(target); + self.at(self.ast.expr_span(target).start); + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::ForEachValue(0)); + self.emit(Op::SetVar(n)); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + self.emit(Op::ForEachValue(1)); + let n = self.name_key(key_of(&property.name)); + self.emit(Op::SetMember(n)); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::ToKey); + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + _ => { + self.expr(target)?; + self.emit(Op::Pop); + self.emit(Op::ForEachValue(0)); + self.throw_invalid_target(); + } + } + Ok(()) + } + fn throw_invalid_target(&mut self) { let s = self.string(&"invalid assignment target".encode_utf16().collect::>()); let depth = self.depth; @@ -854,6 +904,15 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Pushes the nearest XML value in scope that holds the E4X name `name` of the expression + /// `id`, then the name, as an assignment, update or `delete` uses them. + fn xml_scope_ref(&mut self, id: ExprId, name: ExprId) -> CResult<()> { + self.expr(name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeRef); + Ok(()) + } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { for part in parts { match part { @@ -1096,6 +1155,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::DeleteIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(arg, *name)?; + self.emit(Op::DeleteIndex); + } _ => { self.expr(arg)?; self.emit(Op::Pop); @@ -1202,6 +1265,13 @@ impl<'a> FnCompiler<'a> { self.emit(Op::GetIndex); } } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + if op.is_some() { + self.emit(Op::Dup2); + self.emit(Op::GetIndex); + } + } _ => { self.expr(target)?; if op.is_none() { @@ -1222,7 +1292,7 @@ impl<'a> FnCompiler<'a> { let n = self.name_key(key_of(&property.name)); self.emit(Op::SetMember(n)); } - Some(Expr::Index { .. }) => self.emit(Op::SetIndex), + Some(Expr::Index { .. } | Expr::XmlScopeName(_)) => self.emit(Op::SetIndex), _ => self.throw_invalid_target(), } Ok(()) @@ -1246,6 +1316,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::IncIndex { prefix, dec }); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::IncIndex { prefix, dec }); + } _ => { self.expr(target)?; self.emit(Op::ToNumber); diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 883a288991..9c14bedca1 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -397,11 +397,29 @@ impl Interp { let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); self.evaluated = self.evaluated.saturating_add(1); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); - let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval)); + // UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the + // bytes is taken before the source is encoded, and the rest after. A source within the + // source limit must fit in the heap whole before it is encoded; a longer one is refused as + // a syntax error when it is encoded. + let floor = source.len().div_ceil(3); + let fits = |interp: &mut Self| { + let units = source.encode_utf16().count(); + if interp.check_source_units(units).is_ok() { interp.reserve_heap(string_bytes(units)) } else { Ok(()) } + }; + let code = self.prepare_source(floor).and_then(|()| fits(self)).map(|()| { + let units = self.encode_source(&source); + let rest = match &units { + Ok(units) => self.prepare_source(units.len().saturating_sub(floor)), + Err(_) => Ok(()), + }; + rest.map(|()| units.and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval))) + }); + let code = code.and_then(|c| c); self.source_tag = tag; let r = match code { - Ok(code) => self.run_global(code, true), - Err(e) => Err(self.compile_error_to_abrupt(e)), + Ok(Ok(code)) => self.run_global(code, true), + Ok(Err(e)) => Err(self.compile_error_to_abrupt(e)), + Err(limit) => Err(limit), }; end(); r diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 6aaa847ceb..124fe395f3 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -638,6 +638,45 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn an_evaluators_source_takes_fuel_before_it_is_parsed() { + // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. + let ends = Rc::new(Cell::new(0)); + let ended = ends.clone(); + let source: HostFunction = Rc::new(|_| Ok(Data::String(format!("{}7", " ".repeat(1 << 17))))); + let item = HostItem::Evaluator { source, end: Rc::new(move || ended.set(ended.get() + 1)), strict_writes: Rc::new(|| None) }; + let mut r = realm(10_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + let e = r.run("host.evaluate()").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 1, "the evaluator's end runs once"); + // This fuel covers a third of the source's bytes but not all of its code units. + r.set_limits(Limits { fuel: 60_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 2); + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()"), Ok(JsValue::Number(7.0))); + assert_eq!(ends.get(), 3); +} + +#[test] +fn an_evaluators_whole_source_must_fit_in_the_heap_before_it_is_parsed() { + // A third of the source and the other two thirds each fit in the heap; the whole does not. + // The source does not parse, so a parse would throw a syntax error instead. + let n = (1 << 17) + 1; + let source: HostFunction = Rc::new(move |_| Ok(Data::String(format!("{}(", " ".repeat(n - 1))))); + let item = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + let mut r = realm(1_000_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + r.interp().collect_for_room(); + let heap_bytes = r.interp().heap.bytes + crate::heap::string_bytes(n * 5 / 6); + r.set_limits(Limits { fuel: 1_000_000, heap_bytes, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Heap)); + // With room for the whole source, it is parsed. + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Thrown); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index cda117ecac..f5e2ce2ae6 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -9,7 +9,8 @@ //! Before each instruction the interpreter takes one unit of fuel, checks the interrupt flag and, //! when the heap has grown past its threshold, collects garbage. Running out of fuel, the heap //! limit, the frame limit, the re-entry limit and the interrupt flag each end the run with an -//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. +//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. Source +//! compiled during a run takes a unit of fuel for each code unit before it is parsed. //! //! Collection runs only between instructions. An operation that may run JavaScript in the middle //! keeps its operands on the operand stack until it is done, so everything it holds is reachable @@ -617,6 +618,22 @@ impl Interp { Ok(()) } + /// Prepares for parsing `units` code units of source at run time: takes a unit of fuel for + /// each code unit and checks that the source text fits in the heap, before any of it is + /// flattened, parsed or compiled. Running out of fuel uses up what was left. + pub(crate) fn prepare_source(&mut self, units: usize) -> JsResult<()> { + let fuel = u64::try_from(units).unwrap_or(u64::MAX); + if fuel > self.fuel { + self.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.fuel -= fuel; + if self.interrupt.load(Ordering::Relaxed) { + return Err(Abrupt::Limit(LimitKind::Interrupted)); + } + self.reserve_heap(string_bytes(units)) + } + /// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or /// `return` left: those whose stack slot no longer holds their exception. fn drop_left_finally_marks(&mut self) { @@ -926,11 +943,12 @@ impl Interp { Op::XmlEscape(kind) => self.op_xml_escape(kind), Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlScopeRef => self.op_xml_scope_ref(), Op::XmlFilterStart => self.op_xml_filter_start(), Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), Op::XmlFilterEnd => self.op_xml_filter_end(), - Op::ForEachValue => self.op_for_each_value(), + Op::ForEachValue(n) => self.op_for_each_value(n as usize), Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } @@ -2401,6 +2419,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; + self.prepare_source(s.len())?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 28157d6eea..93c487b378 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -500,6 +500,19 @@ fn declarations_take_fuel() { assert_eq!(limit(&mut realm, &format!("Function('var {vars};')();")), LimitKind::Fuel); } +#[test] +fn source_compiled_during_a_run_takes_fuel_before_it_is_parsed() { + // Doubling a string 17 times makes 131,072 spaces in about a hundred instructions, so only + // the compiled source can use up the fuel. + let build = "var s = ' '; for (var i = 0; i < 17; i++) s += s;"; + let mut realm = small(Limits { fuel: 10_000, ..Limits::default() }); + for call in ["eval(s)", "(0, eval)(s)", "Function(s)", "new Function('a', s)"] { + assert_eq!(limit(&mut realm, &format!("{build} {call};")), LimitKind::Fuel, "{call}"); + } + let mut realm = small(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(realm.run(&format!("{build} eval(s + '1') + (0, eval)(s + '2') + Function(s + 'return 3')()")), Ok(JsValue::Number(6.0))); +} + #[test] fn a_lower_heap_limit_applies_to_what_is_already_live() { // A heap already past the new limit ends the next run. @@ -823,13 +836,14 @@ fn long_property_names_count_toward_the_heap_after_their_strings_are_collected() // Each name is a 16,385-unit buffer that only the object's key or the function's binding // holds once the string that made it is garbage, so about 32 of them fill the heap well before // the fuel runs out. In the second script the property lookup flattens a long string each - // time, so collections run. + // time, so collections run. Each eval there also takes fuel for its source, so that run has + // more fuel. let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); let src = "var s = 'x'; while (s.length < 10000) s += s; var o = {}; for (var i = 0; ; i++) o[s + i] = 1;"; assert_eq!(limit(&mut realm, src), LimitKind::Heap); let src = "var s = 'x'; while (s.length < 10000) s += s; \ (function () { for (var i = 0; ; i++) { eval('var ' + s + i + ' = 1'); ({})[s + i]; } })();"; - let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 5_000_000, ..Limits::default() }); assert_eq!(limit(&mut realm, src), LimitKind::Heap); } diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 3c2ab3ed30..fa201c58ff 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -873,8 +873,9 @@ impl<'s> Parser<'s> { } /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code - /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning - /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + /// neither `eval` nor `arguments`), a property, an E4X name standing alone (`@a`, `ns::a`; + /// ECMA-357 section 11.1), or outside strict mode code a call (assigning to its result fails + /// at run time, as ECMA-262 2015 Annex B and web browsers have it). fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { let inner = self.ast.unparenthesized(expr); match self.ast.expr(inner) { @@ -884,7 +885,7 @@ impl<'s> Parser<'s> { } Ok(()) } - Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Member { .. } | Expr::Index { .. } | Expr::XmlScopeName(_)) => Ok(()), Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), } diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f274c42ead..b17d6a2770 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -14,7 +14,8 @@ use crate::{Options, SyntaxError}; /// cannot catch it, and their `finally` blocks do not run. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Limits { - /// Instructions a run may execute. + /// Instructions a run may execute. Source that `eval`, `Function` or a host evaluator + /// compiles during the run also takes one unit for each UTF-16 code unit, before it is parsed. pub fuel: u64, /// Estimated bytes the heap (objects, environments and the strings they hold) may reach /// after a collection. A string longer than a quarter of this many code units cannot be diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 019a9638b0..8d83162a50 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -2863,27 +2863,24 @@ impl Interp { self.replace(n, value) } - pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let n = self.xml_name_only(&name)?; + /// The nearest XML or XMLList value in scope whose [[HasProperty]] accepts `n`, or a + /// ReferenceError when none does (ECMA-357 section 11.1). + fn xml_scope_holder(&mut self, n: &NameRef) -> JsResult { let mut cur = Some(self.frame()?.env); - let mut first = None; + let mut any_xml = false; while let Some(e) = cur { match self.heap.env(e) { Some(Env::Decl { outer, .. }) => cur = *outer, Some(&Env::Obj { object, outer, .. }) => { let v = Value::Object(object); if self.is_xml(&v) { - if first.is_none() { - first = Some(object); - } + any_xml = true; let found = match self.classify(&v) { - Xv::Node(x) => self.node_has_name(x, &n)?, - _ => self.list_has_name(object, &n)?, + Xv::Node(x) => self.node_has_name(x, n)?, + _ => self.list_has_name(object, n)?, }; if found { - first = Some(object); - break; + return Ok(object); } } cur = outer; @@ -2891,13 +2888,26 @@ impl Interp { None => return Err(Abrupt::Internal("environment out of range".into())), } } - let Some(object) = first else { - return Err(self.throw(ErrorType::Reference, "an XML name is used outside any XML value")); - }; + let message = if any_xml { "no XML value in scope has the XML name" } else { "an XML name is used outside any XML value" }; + Err(self.throw(ErrorType::Reference, message)) + } + + pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; let v = self.xml_select(object, &n)?; self.replace(1, Value::Object(v)) } + pub(crate) fn op_xml_scope_ref(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; + self.replace(1, Value::Object(object))?; + self.push(name) + } + pub(crate) fn op_xml_filter_start(&mut self) -> JsResult<()> { let base = self.peek(0)?; let input = match self.classify(&base) { @@ -2951,9 +2961,9 @@ impl Interp { self.replace(1, Value::Object(out)) } - pub(crate) fn op_for_each_value(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let Value::Object(iter) = self.peek(1)? else { return Err(Abrupt::Internal("for-in iterator is not an object".into())) }; + pub(crate) fn op_for_each_value(&mut self, above: usize) -> JsResult<()> { + let name = self.peek(above)?; + let Value::Object(iter) = self.peek(above + 1)? else { return Err(Abrupt::Internal("for-in iterator is not an object".into())) }; let object = match self.heap.object(iter).map(|o| &o.class) { Some(Class::ForIn(state)) => state.object, _ => return Err(Abrupt::Internal("not a for-in iterator".into())), @@ -2965,7 +2975,7 @@ impl Interp { } None => Value::Undefined, }; - self.replace(1, v) + self.push(v) } /// The URI of a Namespace value or of its string form. diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 9fb9452816..99a0100fe3 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -688,6 +688,103 @@ fn a_scope_name_skips_a_list_without_that_name() { truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '2' }"); } +#[test] +fn bare_attribute_assignment_uses_nearest_holder() { + truth("var x = ; with (x) { @b = '2'; } x.@b == '2'"); + truth("var outer = ; var inner = ; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var outer = ; var inner = ; with (outer) with (inner) { @id = '9'; } outer.@id == '1' && inner.@id == '9'"); + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var x = ; with (x) with ({ b: 7 }) { @b = '3'; } x.@b == '3'"); + truth("with () { (@b = 5) === 5 }"); + truth("var x = ; with (x) { @b = @c = 'v'; } x.@b == 'v' && x.@c == 'v'"); + truth("var n = 0; var x = ; with (x) { @[(n++, 'b')] = 'z'; } n === 1 && x.@b == 'z' && x.attributes().length() === 1"); + truth("var x = 1; with (x) { *::c = '2'; } x.c == '2' && x.*.length() === 1"); + truth("var x = ; x.@b = '4'; x.@b == '4'"); +} + +#[test] +fn bare_qualified_assignment_keeps_namespace() { + truth( + "var ns = new Namespace('u'); var x = 10; with (x) { ns::c = '2'; } \ + x.ns::c == '2' && x.ns::c.name().uri === 'u' && x.c == '0' && x.*.length() === 2", + ); + truth( + "var ns = new Namespace('u'); var x = ; with (x) { @ns::t = '5'; } \ + x.@ns::t == '5' && x.@ns::t.name().uri === 'u' && x.@t == '0'", + ); +} + +#[test] +fn bare_assignment_in_filter_mutates_each_matching_item() { + truth("var l = <>; var r = l.(@id = 'z', true); r.length() === 2 && l[0].@id == 'z' && l[1].@id == 'z'"); + truth("var l = <>; var r = l.(@n = 0); r.length() === 0 && l[0].@n == '0' && l[1].@n == '0'"); + truth("var l = <>; var r = l.(@n = 'k'); r.length() === 2"); + truth("var o = ; var l = <>; with (o) { l.(@k += 'x', true); } o.@k == '1xx' && l[0].@k.length() === 0"); +} + +#[test] +fn bare_assignment_without_holder_throws_reference_error_before_rhs() { + assert_eq!(thrown_name("@a = 1"), "ReferenceError"); + assert_eq!(thrown_name("with ({ a: 1 }) { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a += 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a++ }"), "ReferenceError"); + assert_eq!(thrown_name("var ns = new Namespace('u'); with () { ns::c = 1 }"), "ReferenceError"); + truth("var n = 0; var e = ''; try { with () { @a = (n++, 1); } } catch (err) { e = err.name; } e === 'ReferenceError' && n === 0"); + truth( + "var b = 'g'; var x = ; var e = ''; try { with (x) { @b = 'w'; } } catch (err) { e = err.name; } \ + e === 'ReferenceError' && b === 'g' && x.@b.length() === 0 && x.*.length() === 0", + ); +} + +#[test] +fn bare_name_read_without_holder_throws_reference_error() { + assert_eq!(thrown_name("with () { @a }"), "ReferenceError"); + assert_eq!(thrown_name("var l = <>; l.(@id == '1')"), "ReferenceError"); + truth("var o = ; var l = <>; var r; with (o) { r = l.(@id == '1'); } r.length() === 1"); +} + +#[test] +fn bare_scope_compound_and_updates_resolve_once() { + truth("var n = 0; var x = ; with (x) { @[(n++, 't')] += 1; } n === 1 && x.@t == '11'"); + truth("var n = 0; var x = ; with (x) { @[(n++, 't')]++; } n === 1 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = ++@t; } r === 2 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = @t++; } r === 1 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = --@t; } r === 4 && x.@t == '4'"); + truth("var x = ; var r; with (x) { r = @t--; } r === 5 && x.@t == '4'"); +} + +#[test] +fn bare_scope_delete_removes_attribute() { + truth("var x = ; var r; with (x) { r = delete @t; } r === true && x.@t.length() === 0 && x.@u == '2'"); + truth("var x = ; with (x) { delete *::c; } x.c.length() === 0 && x.d.length() === 1"); + assert_eq!(thrown_name("with () { delete @t }"), "ReferenceError"); +} + +#[test] +fn bare_scope_for_targets_assign_each_iteration() { + truth("var x = ; var s = ''; with (x) { for (@k in { p: 1, q: 2 }) s += @k; } s === 'pq' && x.@k == 'q'"); + truth("var x = ; var s = ''; with (x) { for each (@k in [3, 4]) s += @k; } s === '34' && x.@k == '4'"); + assert_eq!(thrown_name("with () { for (@k in { p: 1 }); }"), "ReferenceError"); +} + +#[test] +fn for_each_evaluates_its_target_before_reading_the_value() { + truth("var o = { p: 1 }; var t = {}; for each ((o.p = 2, t).v in o); t.v === 2"); + truth("var o = { p: 1 }; var t = {}; for each (t[(o.p = 3, 'w')] in o); t.w === 3"); + truth("var x = ; var o = { p: 1 }; with (x) { for each (@[(o.p = 2, 'k')] in o); } x.@k == '2'"); + truth("var o = { p: 1, q: 2 }; var s = ''; for each (var v in o) s += v; var t; for each (t in o) s += t; s === '1212' && t === 2"); +} + +#[test] +fn bare_scope_targets_need_e4x_option() { + for src in ["@a = 1", "ns::a = 1", "@a += 1", "++@a", "@a--", "for (@a in o);", "for each (@a in o);", "\"use strict\"; @a = 1"] { + assert!(parse(src, &Options::default()).is_err(), "{src}"); + assert!(parse(src, &e4x()).is_ok(), "{src}"); + } + truth("'use strict'; var l = <>; l.(@b = '2', true); l[0].@b == '2'"); +} + #[test] fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { truth("var x = 1; x[true] == '1' && (true in x) && !(false in x)"); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 00e914ddba..69c7b5ba0e 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -56,6 +56,24 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } +/// An optional param read with `read`: none when it is omitted or null, else an error naming the +/// key when `read` gets nothing from its value. +fn opt(p: &Value, k: &str, read: impl Fn(&Value) -> Option, what: &str) -> Result, String> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => read(v).map(Some).ok_or_else(|| format!("`{k}` must be {what}")), + } +} + +fn opt_bool(p: &Value, k: &str) -> Result, String> { + opt(p, k, Value::as_bool, "true or false") +} + +/// A pointer packet's `mods`: none when omitted or null, else an object of booleans. +fn packet_mods(p: &Value) -> Result, String> { + opt(p, "mods", |m| m.as_object().and_then(|_| serde_json::from_value(m.clone()).ok()), "an object of true or false values") +} + pub(crate) fn pointer_kind(k: &str) -> Option { Some(match k { "down" => PointerKind::Down, @@ -210,28 +228,30 @@ impl Headless { fn pointer(&mut self, p: &Value) -> Result { let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?; - let base: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let base = packet_mods(p)?.unwrap_or_default(); let default = PointerEvent::json_backquote(p, false)?; let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::, _>>()?; + // Every event is read before any is sent, so a malformed packet sends none of them. + let read = events + .iter() + .zip(held) + .map(|(e, backquote)| { + let mods = packet_mods(e)?.unwrap_or(base); + let kind = s(e, "kind").unwrap_or(""); + let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + PointerEvent::json_check(e)?; + if s(e, "space") == Some("screen") { + return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); + } + let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; + let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; + Ok((e, kind, Point::new(x, y), mods, backquote)) + }) + .collect::, String>>()?; self.ensure_document(); let mut requests = vec![]; - for (e, backquote) in events.iter().zip(held) { - let kind = s(e, "kind").unwrap_or(""); - let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; - if s(e, "space") == Some("screen") { - return Err("screen-space pointer events need the desktop app; use document coordinates".into()); - } - let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; - let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - let ev = PointerEvent { - kind, - pos: Point::new(x, y), - mods, - backquote, - pressure: PointerEvent::json_pressure(e), - pen: PointerEvent::json_pen(e), - }; + for (e, kind, pos, mods, backquote) in read { + let ev = PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen: PointerEvent::json_pen(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; // With no frames to show the highlight or apply an edit in later, each event waits for @@ -253,8 +273,8 @@ impl Headless { fn key(&mut self, p: &Value) -> Result { let key = s(p, "key").ok_or("missing `key`")?; - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() }; + let b = |n: &str| opt_bool(p, n).map(|on| on.unwrap_or(false)); + let mods = Mods { shift: b("shift")?, alt: b("alt")?, cmd: b("cmd")?, ctrl: b("ctrl")?, ..Mods::default() }; // A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while // a selection marquee is dragged); otherwise it is a shortcut. let mut tk = tool_key(key); @@ -296,16 +316,18 @@ impl Headless { } fn render(&mut self, p: &Value) -> Result { + let idx = opt(p, "artboard", |v| v.as_u64().and_then(|i| usize::try_from(i).ok()), "a whole number of 0 or more")?.unwrap_or(0); + let scale = opt(p, "scale", Value::as_f64, "a number")?.unwrap_or(1.0).clamp(0.01, 16.0); + let trace_views = opt_bool(p, "traceViews")? == Some(true); + let data = opt_bool(p, "data")? == Some(true); let st = self.session.active().ok_or("no document")?; let doc = st.doc.clone(); - let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; - let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); vectorcraft_render::raster_size(r, scale)?; // Rendered with the options of a PNG export on white, so its pixels are the exported file's. // `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does. let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png); - opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true); + opts.trace_views = trace_views; let img = self.renderer.render_region_with(&doc, r, scale, &opts); // A cancelled render stops drawing: the picture is not whole and is not written. vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?; @@ -317,7 +339,7 @@ impl Headless { vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; out["path"] = json!(path); // `data: true` asks for the image as well as the file. - if p.get("data").and_then(Value::as_bool) == Some(true) { + if data { out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)); } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index a10de68499..ffb6fcb93e 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1452,6 +1452,44 @@ fn headless_backend_methods() { assert!(h.call("ui.resize", json!({})).is_err()); } +/// The headless key, pointer and screenshot calls refuse a value of the wrong type and change +/// nothing; omitting it or giving null keeps its default. +#[test] +fn headless_methods_refuse_params_of_the_wrong_type() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap(); + h.call("ui.tool.select", json!({"tool": "rectangle"})).unwrap(); + let before = h.call("document.json", json!({})).unwrap(); + let drag = |mods: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60, "mods": mods}, {"kind": "up", "x": 60, "y": 60}]}); + let after_rect = |last: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60}, {"kind": "up", "x": 60, "y": 60}, last]}); + for (method, params, key) in [ + ("ui.render", json!({"artboard": "0"}), "artboard"), + ("ui.render", json!({"scale": "2"}), "scale"), + ("ui.render", json!({"traceViews": 1}), "traceViews"), + ("ui.screenshot", json!({"data": "yes", "path": "x.png"}), "data"), + ("ui.key", json!({"key": "Delete", "shift": "true"}), "shift"), + ("ui.pointer", drag(json!({"shift": "yes"})), "mods"), + ("ui.pointer", json!({"mods": 1, "events": []}), "mods"), + ("ui.pointer", drag(json!([true])), "mods"), + // A whole rectangle comes before the malformed event, and none of it is drawn. + ("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"), + ("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "pressure": "bad"})), "pressure"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "holdMs": true})), "holdMs"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "tiltX": "5"})), "tiltX"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "rotation": [0]})), "rotation"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "space": "screeen"})), "space"), + ] { + let e = h.call(method, params.clone()).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); + } + assert_eq!(h.call("document.json", json!({})).unwrap(), before, "a refused call changes nothing"); + let r = h.call("ui.render", json!({"artboard": null, "scale": null, "traceViews": null})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(300), Some(200))); + h.call("ui.pointer", drag(Value::Null)).unwrap(); + assert_ne!(h.call("document.json", json!({})).unwrap(), before, "null mods keep the packet's"); +} + // ---------- workflow tools ---------- fn id_of(result: &Value) -> u64 { diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 17c7ecc912..781a2bf14d 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -259,7 +259,7 @@ pub fn tool_definitions() -> Vec { "radius1": num("Star outer radius"), "radius2": num("Star inner radius"), "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees clockwise (polygon/star; 0: a corner or point straight up)"), + "rotation": num("Rotation in degrees clockwise about the centre (rectangle/ellipse: 0 upright; polygon/star: 0 a corner or point straight up)"), "x1": num("Line start x"), "y1": num("Line start y"), "x2": num("Line end x"), diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c6cf12857f..c493c36a19 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -748,6 +748,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", ), + ( + "help.copyTroubleshootingInfo", + "Copy Troubleshooting Info", + "", + "{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions", @@ -1523,7 +1529,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked | null on the web without path or dataBase64, once the browser's file picker opens (the picked file is handled as above)", ), ( "plugin.cancelRuns", diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 116d1cf0c7..e367221d59 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -211,23 +211,46 @@ pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> { Ok(()) } -/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is -/// looked for there isn't found in image or font bytes. +/// `pdf` without its streams' data, the text of its literal strings and its comments (dictionaries +/// and the rest of the syntax), so that what is looked for there isn't found in image or font +/// bytes, or in a string such as the file identifier, whose base64 hash can hold `/BM`. fn without_streams(pdf: &[u8]) -> Vec { let mut out = Vec::with_capacity(pdf.len().min(1 << 20)); let mut at = 0; - while let Some(s) = find(pdf, b"stream", at) { - let data = s + b"stream".len(); - // `endstream` itself, or a stream keyword that isn't one (inside a name or string). - if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) { - out.extend_from_slice(pdf.get(at..data).unwrap_or_default()); - at = data; - continue; + while let Some(&b) = pdf.get(at) { + match b { + // A literal string: parentheses nest, and a backslash escapes the next byte. + b'(' => { + out.extend_from_slice(b"()"); + let mut depth = 0usize; + at += 1; + while let Some(&c) = pdf.get(at) { + at += 1; + match c { + b'\\' => at += 1, + b'(' => depth += 1, + b')' if depth == 0 => break, + b')' => depth -= 1, + _ => {} + } + } + } + // A comment, to the end of its line. + b'%' => at += pdf.get(at..).unwrap_or_default().iter().take_while(|c| !matches!(c, b'\r' | b'\n')).count(), + // A stream keyword (not `endstream`, and followed by its end of line): its data is left out. + b's' if pdf.get(at..at + 6) == Some(b"stream") + && !(at >= 3 && pdf.get(at - 3..at) == Some(b"end")) + && pdf.get(at + 6).is_some_and(|c| matches!(c, b'\r' | b'\n')) => + { + out.extend_from_slice(b"stream"); + at = find(pdf, b"endstream", at + 6).unwrap_or(pdf.len()); + } + _ => { + out.push(b); + at += 1; + } } - out.extend_from_slice(pdf.get(at..data).unwrap_or_default()); - at = find(pdf, b"endstream", data).unwrap_or(pdf.len()); } - out.extend_from_slice(pdf.get(at..).unwrap_or_default()); out } diff --git a/crates/pdf/src/tests/pdfx.rs b/crates/pdf/src/tests/pdfx.rs index dbc221e0dd..ec22eb0186 100644 --- a/crates/pdf/src/tests/pdfx.rs +++ b/crates/pdf/src/tests/pdfx.rs @@ -224,3 +224,23 @@ fn verify_finds_what_each_standard_forbids() { assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts"))); assert!(verify(&file("<>"), Standard::PdfX4).is_ok()); } + +/// Names inside a string or a comment are text, not syntax: a file identifier, whose base64 hash +/// can hold `/BM` or `/Lab`, does not make a file break its standard. +#[test] +fn verify_skips_names_in_strings_and_comments() { + use crate::pdfx::verify; + let file = |body: &str| format!("%PDF-1.3\n%/BM/Multiply\n1 0 obj\n{body}\nendobj\n").into_bytes(); + for ok in [ + "<>", + "<>", + "<>", + ] { + for standard in [Standard::PdfX1a, Standard::PdfX3] { + assert!(verify(&file(ok), standard).is_ok(), "{standard:?}: {ok}"); + } + } + // A name after a string is still read. + let after = "<>"; + assert!(matches!(verify(&file(after), Standard::PdfX3), Err(PdfError::Unsupported(m)) if m.contains("blend modes"))); +} diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index a16bd8a4f9..b6e17e3ad0 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -736,10 +736,41 @@ fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result>(d: D) -> std::result::Result, D::Error> { use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}; + /// A visitor's methods for a boolean, a number and a string: each is the error its type's + /// `wrong()` gives. + macro_rules! refuse_scalars { + () => { + fn visit_bool(self, _: bool) -> std::result::Result { + Err(E::custom(self.wrong())) + } + fn visit_i64(self, _: i64) -> std::result::Result { + Err(E::custom(self.wrong())) + } + fn visit_u64(self, _: u64) -> std::result::Result { + Err(E::custom(self.wrong())) + } + fn visit_f64(self, _: f64) -> std::result::Result { + Err(E::custom(self.wrong())) + } + fn visit_str(self, _: &str) -> std::result::Result { + Err(E::custom(self.wrong())) + } + }; + } + + /// A string field of an entry: anything else is the error "`{key}` must be a string". + fn string_field<'de, A: MapAccess<'de>>(map: &mut A, key: &str) -> std::result::Result, A::Error> { + Ok(match map.next_value::()? { + Value::String(s) => Ok(s), + _ => Err(format!("`{key}` must be a string")), + }) + } + /// A command's `params`, read in declaration order. struct Params(Vec<(String, Param)>); impl<'de> Deserialize<'de> for Params { @@ -750,6 +781,11 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu /// The entry at place `.0` in the list. struct Entry(usize); + impl Entry { + fn wrong(&self) -> String { + format!("command {} must be an object with an `id` and a `label`", command_name(self.0, "")) + } + } impl<'de> serde::de::DeserializeSeed<'de> for Entry { type Value = Command; fn deserialize>(self, d: D) -> std::result::Result { @@ -759,21 +795,29 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu impl<'de> Visitor<'de> for Entry { type Value = Command; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "command [{}] as {{id, label, params?}}", self.0) + f.write_str(&self.wrong()) + } + refuse_scalars!(); + fn visit_unit(self) -> std::result::Result { + Err(E::custom(self.wrong())) + } + fn visit_seq>(self, _: A) -> std::result::Result { + Err(A::Error::custom(self.wrong())) } fn visit_map>(self, mut map: A) -> std::result::Result { let (mut id, mut label, mut params) = (None::, None::, None::); let named = |id: &Option, e: &dyn std::fmt::Display| { A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default()))) }; - while let Some(key) = map.next_key::().map_err(|e| named(&id, &e))? { + // A key and an ignored value can only fail as JSON syntax, which keeps serde's own error. + while let Some(key) = map.next_key::()? { let repeated = match key.as_str() { "id" if id.is_none() => { - id = Some(map.next_value().map_err(|e| named(&id, &e))?); + id = Some(string_field(&mut map, "id")?.map_err(|e| named(&id, &e))?); false } "label" if label.is_none() => { - label = Some(map.next_value().map_err(|e| named(&id, &e))?); + label = Some(string_field(&mut map, "label")?.map_err(|e| named(&id, &e))?); false } "params" if params.is_none() => { @@ -782,7 +826,7 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu } "id" | "label" | "params" => true, _ => { - map.next_value::().map_err(|e| named(&id, &e))?; + map.next_value::()?; false } }; @@ -801,16 +845,22 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu } struct List; + impl List { + fn wrong(&self) -> &'static str { + "`commands` must be a list of commands, each with an `id` and a `label`" + } + } impl<'de> Visitor<'de> for List { type Value = Vec; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("`commands` as a list of {id, label, params?}") + f.write_str(self.wrong()) } + refuse_scalars!(); fn visit_unit(self) -> std::result::Result { Ok(Vec::new()) } fn visit_map>(self, _: A) -> std::result::Result { - Err(A::Error::custom("`commands` must be a list of {id, label, params?}")) + Err(A::Error::custom(self.wrong())) } fn visit_seq>(self, mut seq: A) -> std::result::Result { let mut out = Vec::new(); @@ -1215,14 +1265,27 @@ mod tests { r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#, "command `x`: parameter `r` is declared twice", ), - (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), - (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0] must be an object with an `id` and a `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},null]}"#, "command [1] must be an object"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[["x"]]}"#, "command [0] must be an object"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":1,"label":"A"}]}"#, "command [0]: `id` must be a string"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":true}]}"#, "command `x`: `label` must be a string"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","id":"y","label":"A"}]}"#, "command `x`: duplicate field `id`"), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, + "`commands` must be a list of commands, each with an `id` and a `label`", + ), + (r#"{"id":"a","name":"x","kind":"filter","commands":"x"}"#, "`commands` must be a list of commands"), + (r#"{"id":"a","name":"x","kind":"filter","commands":7}"#, "`commands` must be a list of commands"), + (r#"{"id":"a","name":"x","kind":"filter","commands":true}"#, "`commands` must be a list of commands"), (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), (r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"), (too_many.as_str(), "at most 32 `commands`"), ] { let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); assert!(e.contains(says), "{bad}: {e}"); + // The message is the manifest reader's own, with serde's place in the manifest once. + assert!(!e.contains("invalid type") && !e.contains("expected") && e.matches(" at line ").count() <= 1, "{bad}: {e}"); } let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1)); assert!(Manifest::parse(long_id.as_bytes()).is_err()); diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index b8e5840c26..1c5a895161 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -37,8 +37,8 @@ use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache}; use live_paint::LpHover; use live_paint::lp_color; pub use live_paint::{ - LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, - LivePaintSelectionTool, + LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_FILL_CHUNK_POINTS, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, + LivePaintBucketTool, LivePaintSelectionTool, }; pub use lookup::{ EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 08fca14327..010e423743 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -114,7 +114,8 @@ enum LpPaint { } /// Live Paint Bucket: by default, a click fills the face under the cursor with the current fill, -/// and Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or +/// and dragging fills the faces crossed in that group as one undo step (Escape cancels). +/// Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or /// stroke into the paint defaults; Alt+Shift-click samples only the color under the pointer. /// Clicking selected ordinary paths without Alt makes them a Live Paint group first. Its Tool /// Options (double-click the tool) are `paintFills` (on) and `paintStrokes` (off), `highlight` @@ -130,6 +131,8 @@ pub struct LivePaintBucketTool { pub(super) hover: LpHover, /// Where the pointer last moved, and whether Shift and Alt were held. at: Option<(Point, bool, bool)>, + /// The fill command and polyline previewed from the press's snapshot. + fill_drag: Option<(Value, Vec>)>, fills: bool, strokes: bool, highlight: bool, @@ -137,12 +140,16 @@ pub struct LivePaintBucketTool { width: f64, } +/// Maximum points in one Live Paint fill path chunk. +pub const LP_FILL_CHUNK_POINTS: usize = 4096; + impl Default for LivePaintBucketTool { fn default() -> Self { Self { cache: RegionCache::live_paint(), hover: LpHover::None, at: None, + fill_drag: None, fills: true, strokes: false, highlight: true, @@ -153,6 +160,36 @@ impl Default for LivePaintBucketTool { } impl LivePaintBucketTool { + fn begin_fill(&mut self, params: Value, p: Point) -> Vec { + self.fill_drag = Some((params.clone(), vec![vec![p]])); + vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)] + } + + fn drag_fill(&mut self, p: Point) -> Vec { + let Some((params, chunks)) = &mut self.fill_drag else { return vec![] }; + let Some(points) = chunks.last_mut() else { return vec![] }; + let Some(&last) = points.last() else { return vec![] }; + if last == p { + return vec![]; + } + if points.len() == LP_FILL_CHUNK_POINTS { + // Share the boundary point so no segment is lost between chunks. + chunks.push(vec![last, p]); + } else { + points.push(p); + } + let mut params = params.clone(); + let mut chunks = chunks.iter().map(|chunk| json!(chunk.iter().map(|p| [p.x, p.y]).collect::>())); + if let Some(first) = chunks.next() { + params["points"] = first; + } + let continuation: Vec<_> = chunks.collect(); + if !continuation.is_empty() { + params["continuation"] = Value::Array(continuation); + } + vec![Action::Preview("livePaint.fill".into(), params)] + } + /// What a click paints, Shift held or not. Shift switches Paint Fills alone to Paint Strokes /// alone and back, and switches both to Paint Fills alone. fn paints(&self, shift: bool) -> LpPaint { @@ -191,6 +228,12 @@ impl Tool for LivePaintBucketTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + // A second press finishes the preceding click, including callers without a release. + if ev.kind == PointerKind::Down && self.fill_drag.take().is_some() { + let mut acts = vec![Action::Commit]; + acts.extend(self.pointer(cx, ev)); + return acts; + } match ev.kind { PointerKind::Move => { self.at = Some((p, ev.mods.shift, ev.mods.alt)); @@ -232,18 +275,42 @@ impl Tool for LivePaintBucketTool { return vec![]; } if let Some(g) = live_paint_at(cx.doc, p) { - return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; + return self.begin_fill(json!({ "group": g.0, "point": point }), p); } if self.cache.at(cx, p).is_some() { let roots = sorted_roots(cx.doc, &cx.selection.objects); - return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; + return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p); } vec![] } + PointerKind::Drag => self.drag_fill(p), + PointerKind::Up => { + let mut acts = self.drag_fill(p); + if self.fill_drag.take().is_some() { + acts.push(Action::Commit); + } + acts + } _ => vec![], } } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] } + } + + fn busy(&self) -> bool { + self.fill_drag.is_some() + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + key == ToolKey::Escape && self.busy() + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] } + } + fn overlays(&self, _cx: &ToolContext) -> Vec { lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32) } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 4e3919db89..eeaf8f0ea7 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1045,6 +1045,25 @@ fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) { (gid, ids) } +#[test] +fn bucket_long_drag_keeps_turns_and_release_after_the_point_limit() { + let (d, gid, ..) = live_group_doc(); + let s = Selection::default(); + let paint = paint(); + let cx = cx(&d, &s, &paint); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + for i in 1..4096 { + t.pointer(&cx, &ev(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 25.0)); + } + t.pointer(&cx, &ev(PointerKind::Drag, 25.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 100.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); + let [Action::Preview(id, params), Action::Commit] = acts.as_slice() else { panic!("release must extend the drag before committing") }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(params["group"], gid.0); +} + #[test] fn bucket_click_fills_face_and_shift_click_strokes_edge() { let (d, gid, f2, _e) = live_group_doc(); @@ -1058,9 +1077,10 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { let ov = t.overlays(&cx); assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. })); let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); - let Action::Exec(id, v) = &acts[0] else { panic!() }; + let [Action::Begin(_), Action::Preview(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; assert_eq!(id, "livePaint.fill"); assert_eq!(v["group"], gid.0); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)), vec![Action::Commit]); let shift = Mods { shift: true, ..Default::default() }; let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift)); let Action::Exec(id, _) = &acts[0] else { panic!() }; @@ -1074,9 +1094,15 @@ fn bucket_options_choose_what_a_click_paints() { let p = paint(); let cx = cx(&d, &s, &p); let shift = Mods { shift: true, ..Default::default() }; - let fill = |x: f64| vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))]; + let fill = |x: f64| { + vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))] + }; let stroke = |x: f64| vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": gid.0, "point": [x, 25.0], "tolerance": 4.0 }))]; - let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| { + let acts = t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + t.pointer(&cx, &ev(PointerKind::Up, x, 25.0).with_mods(m)); + acts + }; let hovered = |t: &mut LivePaintBucketTool, x: f64, m: Mods| { t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m)); match t.overlays(&cx).as_slice() { @@ -1162,7 +1188,9 @@ fn bucket_options_set_the_highlight() { // Highlight off: nothing is highlighted, and a click still paints. t.set_option("highlight", &json!(false)); assert_eq!(highlight(&mut t), None); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)).len(), 1); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, _)] if id == "livePaint.fill")); + t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); t.set_option("highlight", &json!(true)); assert!(highlight(&mut t).is_some()); } @@ -1326,7 +1354,7 @@ fn bucket_on_plain_selection_makes_live_paint() { let cx = cx(&d, &s, &p); let mut t = LivePaintBucketTool::default(); let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)); - let Action::Exec(id, v) = &acts[0] else { panic!() }; + let [Action::Begin(_), Action::Preview(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; assert_eq!(id, "livePaint.fill"); assert_eq!(v["ids"], json!([a.0, b.0])); } @@ -1344,7 +1372,13 @@ fn bucket_rebuilds_its_map_for_a_new_revision_at_the_same_address() { d = two_rects_at(300.0).0; let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; let acts = t.pointer(&cx2, &ev(PointerKind::Down, 375.0, 50.0)); - assert_eq!(acts, vec![Action::Exec("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))]); + assert_eq!( + acts, + vec![ + Action::Begin("Live Paint Bucket".into()), + Action::Preview("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] })) + ] + ); } #[test] @@ -1357,7 +1391,8 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() { let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); let acts = t.pointer(&cx1, &ev(PointerKind::Down, 75.0, 50.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "livePaint.fill"), "{acts:?}"); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, _)] if id == "livePaint.fill"), "{acts:?}"); + t.pointer(&cx1, &ev(PointerKind::Up, 75.0, 50.0)); // The fill left the document as it was: the map still fits it. t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0)); assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing"); @@ -1745,8 +1780,9 @@ fn live_paint_tools_skip_hidden_faces() { let mut bucket = LivePaintBucketTool::default(); bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); - let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); - assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); + let fill = Action::Preview("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Begin("Live Paint Bucket".into()), fill]); + bucket.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); let mut sel = LivePaintSelectionTool::default(); sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index ece5231bd2..9b53d2404c 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -109,6 +109,19 @@ impl PointerEvent { pub fn json_backquote(v: &Value, default: bool) -> Result { v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned())) } + /// Checks the optional keys of a JSON pointer event (control channel, MCP): `pressure`, + /// `holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers, and `space` must be `doc` or + /// `screen`. A key that is absent or null keeps its default. + pub fn json_check(e: &Value) -> Result<(), String> { + let given = |k: &str| e.get(k).filter(|v| !v.is_null()); + if let Some(k) = ["pressure", "holdMs", "tiltX", "tiltY", "rotation"].into_iter().find(|k| given(k).is_some_and(|v| v.as_f64().is_none())) { + return Err(format!("`{k}` must be a number")); + } + if given("space").is_some_and(|v| !matches!(v.as_str(), Some("doc" | "screen"))) { + return Err("`space` must be doc or screen".to_owned()); + } + Ok(()) + } } #[cfg(test)] @@ -135,6 +148,27 @@ mod tests { assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}"); } } + + #[test] + fn json_check_refuses_pen_values_and_spaces_of_the_wrong_type() { + use serde_json::json; + for ok in [ + json!({}), + json!({"pressure": 0.5, "holdMs": 10, "tiltX": -5, "tiltY": 5, "rotation": 400, "space": "screen"}), + json!({"pressure": null, "space": null}), + json!({"space": "doc"}), + ] { + assert_eq!(PointerEvent::json_check(&ok), Ok(()), "{ok}"); + } + for k in ["pressure", "holdMs", "tiltX", "tiltY", "rotation"] { + for bad in [json!("1"), json!(true), json!([1])] { + assert_eq!(PointerEvent::json_check(&json!({k: bad})), Err(format!("`{k}` must be a number")), "{k}: {bad}"); + } + } + for bad in [json!("screeen"), json!(true), json!(1)] { + assert_eq!(PointerEvent::json_check(&json!({"space": bad})), Err("`space` must be doc or screen".to_owned()), "{bad}"); + } + } } /// Keys tools care about. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7b69fbfb8d..3ce7143224 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -135,6 +135,7 @@ impl VectorcraftApp { last_window_title: String::new(), fullscreen_for_mode: false, graphics_adapter: None, + beacon: &crate::hang_beacon::BEACON, place: Default::default(), system_paste: false, clipboard_image_request: None, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 04c6ecb13a..16e8a14cc1 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -73,6 +73,7 @@ fn substitute_warnings_follow_current_font_inventory() { impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { + self.beacon.command(id); let touch_workspace = self.session.prefs.touch_workspace; // A file dialog it shows off the UI thread runs it again with the path picked. let result = if self.picks.is_entry_free() { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 8674942538..3bb451bfd6 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,5 @@ use super::{VectorcraftApp, io, place}; +use crate::services::Inbox; impl VectorcraftApp { /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs @@ -13,13 +14,25 @@ impl VectorcraftApp { self.services.dialogs_are_sheets && self.file_dialog_open() } - pub(super) fn drain_inbox(&mut self) { - let arrived: Vec<(String, Vec)> = - self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); - for (name, bytes) in arrived { - if let Err(e) = io::open_bytes(self, &name, &bytes, None) { - io::report_open_error(self, &name, &e); - } + pub(crate) fn drain_inbox(&mut self) { + fn take(q: Option<&Inbox>) -> Vec<(String, Vec)> { + q.map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default() + } + let answers = self.services.picked_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))); + // The web's file picker answers for automation when automation opened it; the page's script + // is automation. + let mut arrived: Vec<_> = take(self.services.inbox.as_ref()).into_iter().map(|f| (f, false)).collect(); + for answer in answers.unwrap_or_default() { + let automated = self.picks.answer_web_open(); + arrived.extend(answer.map(|f| (f, automated))); + } + arrived.extend(take(self.services.page_inbox.as_ref()).into_iter().map(|f| (f, true))); + for ((name, bytes), automated) in arrived { + crate::picks::as_automated(self, automated, |app| { + if let Err(e) = io::open_bytes(app, &name, &bytes, None) { + io::report_open_error(app, &name, &e); + } + }); } place::drain(self); } diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 8f4ce246f5..b27bb494aa 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -36,6 +36,13 @@ impl VectorcraftApp { self.ui.answering_control || self.synthetic_frame } + /// Did automation ask for what is being handled now: [`Self::automation_is_acting`], or the + /// answer of a file dialog that automation opened ([`crate::picks::as_automated`]), even when a + /// person picked the file in it? + pub(crate) fn automation_asked(&self) -> bool { + self.automation_is_acting() || self.picks.automated() + } + /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4f962fdb5d..9067bdb297 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2850,10 +2850,13 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() { "{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}" ); } - // Deliver the actual replacement and verify that it replaces the cached key. + // Deliver the actual replacement and verify that it replaces the cached key. The frame + // reports a slow render: when a parallel test makes a placed document's bitmap, the key's + // process-wide `placed` generation moves, and the next frame goes to the controlled worker + // instead of rendering over the delivered frame on this thread. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0, reused: false }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0, reused: false }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5199230202..11edb6de6e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -474,9 +474,24 @@ fn key_from(name: &str) -> Option { /// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on /// macOS, Ctrl elsewhere). -fn modifiers(p: &Value) -> egui::Modifiers { - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } +fn modifiers(p: &Value) -> Result { + let b = |n: &str| crate::menus::opt_bool(p, n).map(|on| on.unwrap_or(false)); + let cmd = b("cmd")?; + Ok(egui::Modifiers { alt: b("alt")?, ctrl: b("ctrl")?, shift: b("shift")?, mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd }) +} + +/// An optional number param of a control request as `f32`, `default` when it is omitted or null. +fn coord(p: &Value, key: &str, default: f32) -> Result { + crate::menus::opt_number(p, key).map(|n| n.map_or(default, |n| n as f32)) +} + +/// A pointer packet's `mods`: none when omitted or null, else an object of booleans. +fn packet_mods(value: Option<&Value>) -> Result, String> { + match value { + None | Some(Value::Null) => Ok(None), + Some(m) if !m.is_object() => Err("`mods` must be an object of true or false values".to_string()), + Some(m) => serde_json::from_value(m.clone()).map(Some).map_err(|e| format!("`mods` must be an object of true or false values: {e}")), + } } pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { @@ -512,10 +527,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; + let m = match modifiers(p) { + Ok(m) => m, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) { return err(e); } - let m = modifiers(p); app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); // Typed text comes with the press, so it shares the key's frame and modifiers. if let Some(t) = s("text") { @@ -526,31 +544,39 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.move" => { + let pos = match coord(p, "x", 0.0).and_then(|x| Ok(egui::pos2(x, coord(p, "y", 0.0)?))) { + Ok(pos) => pos, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 1) { return err(e); } - let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); app.synthetic.push(egui::Event::PointerMoved(pos)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => synthetic_click(app, ctx, req), "ui.wheel" => { + // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways + // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. + let given = + (|| Ok::<_, String>(([coord(p, "dx", 0.0)?, coord(p, "dy", 0.0)?, coord(p, "x", 0.0)?, coord(p, "y", 0.0)?], modifiers(p)?)))(); + let ([dx, dy, x, y], modifiers) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 2) { return err(e); } - // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways - // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let unit = if s("unit") == Some("point") { egui::MouseWheelUnit::Point } else { egui::MouseWheelUnit::Line }; // Notches, not a flood: a turn stays within what a real wheel sends. - let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + let delta = egui::vec2(dx, dy).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, - modifiers: modifiers(p), + modifiers, source: egui::MouseWheelSource::Unknown, }); ctx.request_repaint(); @@ -615,8 +641,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.resize" => { - let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32; - let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32; + let (w, h) = match coord(p, "width", 1440.0).and_then(|w| Ok((w, coord(p, "height", 900.0)?))) { + Ok(size) => size, + Err(e) => return err(e), + }; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(Value::Null) } @@ -629,32 +657,48 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) { return err(e); } - if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { + let flags = crate::menus::opt_bool(p, "focus").and_then(|focus| Ok((focus, crate::menus::opt_bool(p, "data")?))); + let (focus, data) = match flags { + Ok(flags) => flags, + Err(e) => return err(e), + }; + if focus.unwrap_or(true) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } ctx.request_repaint(); - Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) } + Outcome::Screenshot { path: s("path").map(str::to_string), data: data == Some(true) } } "ui.render" => { + let given = (|| { + Ok::<_, String>(( + crate::menus::opt_index(p, "artboard")?, + crate::menus::opt_number(p, "scale")?, + crate::menus::opt_bool(p, "traceViews")?, + crate::menus::opt_bool(p, "data")?, + )) + })(); + let (index, scale, trace_views, data) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); // The artboard asked for (0-based, as headless), else the first. - let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok()); - let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; - let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); + let Some(r) = doc.artboards.get(index.unwrap_or(0)).map(|a| a.rect) else { return err("no such artboard") }; + let scale = scale.unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); } // Rendered with the options of a PNG export on white, so its pixels are the exported file's. // `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does. let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png); - opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true); + opts.trace_views = trace_views == Some(true); let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts); let png = match img.to_png() { Ok(png) => png, Err(e) => return err(e), }; - let data = p.get("data").and_then(Value::as_bool) == Some(true); + let data = data == Some(true); match s("path") { Some(path) => match app.services.write.as_mut() { Some(w) => wrap( @@ -857,7 +901,7 @@ fn rotate_view_pointer( /// A Rotate View packet accepts only the documented boolean modifier fields. fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> { - let Some(value) = value else { return Ok(()) }; + let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(()) }; let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) }; if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) { return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into()); @@ -906,9 +950,36 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con if rotating && let Err(error) = validate_rotate_view_events(p, events) { return err(error); } - let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + // Every event is read before any is sent, so a malformed packet sends none of them. + let read = packet_mods(p.get("mods")).and_then(|base| { + let base = base.unwrap_or_default(); + events + .iter() + .map(|e| { + let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + other => return Err(format!("unknown pointer kind `{other}`")), + }; + PointerEvent::json_check(e)?; + let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); + Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) + }) + .collect::, String>>() + }); + let read = match read { + Ok(read) => read, + Err(e) => return err(e), + }; let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); + if !rotating && xf.is_none() && events.iter().any(screen) { + return err("no canvas yet"); + } let from = app.session.input_count(); let held = PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::, _>>()); @@ -916,19 +987,8 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Ok(held) => held, Err(e) => return err(e), }; - for (e, backquote) in events.iter().zip(held) { - let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { - "down" => PointerKind::Down, - "drag" => PointerKind::Drag, - "up" => PointerKind::Up, - "move" => PointerKind::Move, - "doubleclick" | "dblclick" => PointerKind::DoubleClick, - other => return err(format!("unknown pointer kind `{other}`")), - }; - let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); - let screen = e.get("space").and_then(Value::as_str) == Some("screen"); - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + for ((e, backquote), (kind, x, y, mods)) in events.iter().zip(held).zip(read) { + let screen = screen(e); if rotating { if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { return err(e); @@ -987,18 +1047,24 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { return err("synthetic input event count is out of range"); }; + // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let drag = req.method == "ui.drag"; + let given = (|| { + let a = egui::pos2(coord(p, "x", 0.0)?, coord(p, "y", 0.0)?); + let end = if drag { egui::pos2(coord(p, "toX", 0.0)?, coord(p, "toY", 0.0)?) } else { a }; + Ok::<_, String>((a, end, modifiers(p)?)) + })(); + let (a, end, modifiers) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, events) { return err(e); } - // Screen-space (egui points) pointer input through egui itself: reaches every widget. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, }; - let modifiers = modifiers(p); - let a = egui::pos2(f("x"), f("y")); - let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { @@ -1020,6 +1086,11 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + // A flag of the wrong type is refused before anything is set. + let flags = ["rulers", "outline", "grid", "smartGuides", "boundingBox", "controlBar", "imageTracePreview"]; + if let Some(e) = flags.iter().find_map(|k| crate::menus::opt_bool(p, k).err()) { + return err(e); + } // Outline is a setting of the active document's view: without one, nothing is set. if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() { return err("no document"); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0a9c042a1e..1763fd679a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -314,6 +314,93 @@ fn render_takes_the_artboard_asked_for() { assert_eq!(got, size.map(Some), "{params}: {r}"); } assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); + for (params, key) in [ + (json!({"artboard": "1"}), "artboard"), + (json!({"artboard": -1}), "artboard"), + (json!({"scale": "0.5"}), "scale"), + (json!({"traceViews": 1}), "traceViews"), + (json!({"data": "yes"}), "data"), + ] { + let r = request("ui.render", params.clone()); + assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{params}: {r}"); + } + let r = request("ui.render", json!({"artboard": null, "scale": null})); + assert_eq!([&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64), [Some(64), Some(64)], "null keeps the default: {r}"); +} + +/// The control channel's input methods refuse a number or switch of the wrong type before they +/// queue any event; omitting it keeps its default. +#[test] +fn control_input_refuses_params_of_the_wrong_type() { + let move_to = |mods: Value| json!({"events": [{"kind": "move", "x": 5, "y": 5}, {"kind": "move", "x": 6, "y": 6, "mods": mods}]}); + for (method, params, key) in [ + ("ui.move", json!({"x": "10"}), "x"), + ("ui.move", json!({"y": true}), "y"), + ("ui.wheel", json!({"dy": "1"}), "dy"), + ("ui.wheel", json!({"dy": 1, "shift": "yes"}), "shift"), + ("ui.click", json!({"x": [1]}), "x"), + ("ui.click", json!({"cmd": 1}), "cmd"), + ("ui.drag", json!({"toX": "5"}), "toX"), + ("ui.key", json!({"key": "Enter", "alt": "true"}), "alt"), + ("ui.resize", json!({"width": "800"}), "width"), + ("ui.screenshot", json!({"focus": "no"}), "focus"), + ("ui.screenshot", json!({"data": 1}), "data"), + ("ui.set", json!({"rulers": "true"}), "rulers"), + ("ui.set", json!({"grid": true, "controlBar": 0}), "controlBar"), + ("ui.pointer", json!({"events": [{"kind": "move", "x": "5", "y": 0}]}), "x"), + ("ui.pointer", move_to(json!({"shift": "yes"})), "mods"), + ("ui.pointer", move_to(json!([true])), "mods"), + ("ui.pointer", json!({"mods": "shift", "events": [{"kind": "move", "x": 1, "y": 1}]}), "mods"), + ] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic.push(egui::Event::Text("pending".into())); + let before = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar); + let r = input_request(&mut app, method, params.clone()); + assert_eq!(r["ok"], false, "{method} {params}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{method} {params}: {r}"); + let after = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar); + assert_eq!(after, before, "{method} {params}: nothing is queued or set"); + } + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert_eq!(input_request(&mut app, "ui.move", json!({"x": null, "y": 4}))["ok"], true); + assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(0.0, 4.0))); +} + +/// The app's own commands refuse a param of the wrong type and change nothing; omitting it or +/// giving null keeps its default. +#[test] +fn ui_commands_refuse_params_of_the_wrong_type() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("view.setZoom", json!({"zoom": 300, "center": [5, 5]})).unwrap(); + let view = |app: &VectorcraftApp| app.view().map(|v| (v.zoom, v.center.x, v.center.y)); + let before = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone()); + for (id, params, key) in [ + ("view.setZoom", json!({"zoom": "200"}), "zoom"), + ("view.setZoom", json!({"zoom": 200, "center": [1]}), "center"), + ("view.setZoom", json!({"zoom": 200, "center": "0,0"}), "center"), + ("view.screenMode", json!({"mode": "2"}), "mode"), + ("view.screenMode", json!({"mode": 1.5}), "mode"), + ("window.panel", json!({"panel": "layers", "toggle": "true"}), "toggle"), + ("window.panel", json!({"panel": "layers", "toggle": true, "toggleGroup": 1}), "toggleGroup"), + ("file.revert", json!({"confirmed": "yes"}), "confirmed"), + ("file.close", json!({"index": "0"}), "index"), + ("ui.newSwatch", json!({"spot": "true"}), "spot"), + ("ui.newSwatch", json!({"global": 1}), "global"), + ("ui.symbolOptions", json!({"edit": 1}), "edit"), + ("ui.batchDialog", json!({"set": "1"}), "set"), + ("ui.batchDialog", json!({"action": -1}), "action"), + ("file.exportSelection", json!({"multiple": "true"}), "multiple"), + ] { + let e = app.run(id, params.clone()).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{id} {params}: {e}"); + let after = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone()); + assert_eq!(after, before, "{id} {params}: nothing changes"); + } + app.run("view.setZoom", json!({"zoom": null, "center": null})).unwrap(); + assert_eq!(app.view().map(|v| v.zoom), Some(crate::state::zoom_from_display_percent(100.0, app.session.prefs.display_print_size))); } #[test] @@ -370,6 +457,35 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { } } +/// `ui.pointer` refuses an unknown event kind, a pen value or `space` of the wrong type, or a +/// screen-space event with no canvas, before any of the packet's events runs. +#[test] +fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(input_request(&mut app, "ui.tool.select", json!({"tool": "selection"}))["ok"], true); + let before = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let moves = [json!({"kind": "down", "x": 125, "y": 125}), json!({"kind": "drag", "x": 145, "y": 125}), json!({"kind": "up", "x": 145, "y": 125})]; + for (last, error) in [ + (json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"), + (json!({"kind": "move", "x": 145, "y": 125, "pressure": "bad"}), "`pressure` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "holdMs": "800"}), "`holdMs` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "tiltY": true}), "`tiltY` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "rotation": "90"}), "`rotation` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screeen"}), "`space` must be doc or screen"), + (json!({"kind": "move", "x": 145, "y": 125, "space": true}), "`space` must be doc or screen"), + ] { + let events = moves.iter().cloned().chain([last]).collect::>(); + let r = input_request(&mut app, "ui.pointer", json!({"events": events})); + assert_eq!(r["ok"], false, "{error}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains(error)), "{r}"); + assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(), &before, "{error}: the drag does not run"); + } +} + /// `ui.pointer` replies with the key object: a click on one of two selected objects makes it /// the key, and a click on the key again lets it go. #[test] diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index cc52623931..498d854fe9 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,8 +1,8 @@ //! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for //! cutting software and shows Custom again once one is changed), version, scale (1 unit of the -//! art = N drawing units) and lineweights, number of colours, the format of placed images, -//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for -//! Appearance and Outline Text. +//! art = N drawing units) and lineweights, number of colours, the format of placed images and +//! Include Placed Images, Preserve Appearance or Maximize Editability, and Export Selected Art +//! Only, Alter Paths for Appearance, Outline Text and Include Fill Hatches. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file @@ -58,6 +58,8 @@ fn preset_fields(preset: DxfPreset) -> Map { ("scaleLineweights", json!(o.scale_lineweights)), ("colors", json!(o.colors.id())), ("rasterFormat", json!(o.raster.id())), + ("includeImages", json!(o.include_images)), + ("includeHatches", json!(o.include_hatches)), ("preserve", json!(o.preserve.id())), ("alterPaths", json!(o.alter_paths)), ("outlineText", json!(o.outline_text)), @@ -140,6 +142,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect(); choice(ui, d, "rasterFormat", tl!("Raster File Format:"), &rasters); + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + check(ui, d, "includeImages", tl!("Include Placed Images")); + }); ui.add_space(10.0); widgets::subheader(ui, tl!("Export Option")); let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default(); @@ -162,6 +168,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if widgets::check(ui, tl!("Outline Text"), appearance || d.bool("outlineText"), !appearance) { d.fields.insert("outlineText".into(), json!(!d.bool("outlineText"))); } + check(ui, d, "includeHatches", tl!("Include Fill Hatches")); ui.add_space(8.0); super::form::caption( ui, diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 246aaf23f1..c0ed169e9b 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -103,7 +103,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("all".into(), json!(all)); } let mut range = d.str("range"); - if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(90.0)).changed() { + let field = egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(90.0).hint_text("1-3, 5"); + if ui.add_enabled(!all, field).changed() { d.fields.insert("range".into(), json!(range)); } }); diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 88f9d35c80..54c98052bf 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -784,8 +784,7 @@ fn save_preset_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) /// ones → the names added /// (none on the web, where the picked file arrives later and imports through `io::open_bytes`). pub(crate) fn import_presets(app: &mut VectorcraftApp) -> Result, String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(vec![]); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index e95d0e683a..a8bcdde6e4 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -197,8 +197,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Flattener presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 10f7e0e451..f29b25e9e1 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -173,8 +173,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index 7432bd3b2d..4772fa133b 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -191,8 +191,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 63158b7329..706188f6da 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -341,6 +341,21 @@ fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result /// The dialog kind of the approval of a plug-in install. pub const INSTALL_KIND: &str = "pluginInstall"; +/// Who asked for a plug-in install, fixed when the request is made. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InstallOrigin { + /// The person at the keyboard: Install Plug-in…, File › Open or a drop. + Person, + /// A control or MCP request, a frame of synthetic input, or a file dialog that one of them + /// opened ([`VectorcraftApp::automation_asked`]): nothing in the dialog installs it, and the + /// person starts Install Plug-in… to install it. + Automation, +} + +/// What [`ask_install`] answers, as `approval`, for a question automation asked: the person has to +/// start Install Plug-in… to install the module. +pub(crate) const PERSON_MUST_START_INSTALL: &str = "personMustStartInstall"; + /// A plug-in install waiting for a person's answer. pub(crate) struct PendingInstall { /// The question and the lines under it, in the UI language. @@ -350,6 +365,8 @@ pub(crate) struct PendingInstall { pub params: Value, /// What OK approves: the module the question is about. pub approval: InstallApproval, + /// Who asked; a question automation asked has no OK that installs. + pub origin: InstallOrigin, /// How long the question has been on screen; [`ask_install`] starts it over. pub shown: Shown, } @@ -439,27 +456,44 @@ pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { }; /// Asks a person whether to install what `pending` describes, its question not yet shown, even when -/// it reads as the one it replaces. → `{pending}`. +/// it reads as the one it replaces. → `{pending}`, and `approval: "personMustStartInstall"` when +/// automation asked. pub(crate) fn ask_install(app: &mut VectorcraftApp, mut pending: PendingInstall) -> Value { pending.shown = Shown::default(); + let automated = pending.origin == InstallOrigin::Automation; app.pending_plugin_install = Some(pending); app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); + if automated { + return json!({ "pending": INSTALL_KIND, "approval": PERSON_MUST_START_INSTALL }); + } json!({ "pending": INSTALL_KIND }) } +/// What the approval says when automation asked for the install; the error of its OK +/// ([`install_confirm`]) is the same text. +fn automation_asked_note() -> &'static str { + tl!("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.") +} + /// The question, the lines under it, and OK, which is disabled until the question has been shown -/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. +/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. When automation asked, a note +/// says so and OK stays disabled. fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction { let Some(pending) = app.pending_plugin_install.as_mut() else { return DialogAction::Cancel }; - let enabled = pending.shown.drawn(ui); + let person = pending.origin == InstallOrigin::Person; + let enabled = pending.shown.drawn(ui) && person; ui.label(egui::RichText::new(&pending.message).strong()); ui.add_space(8.0); ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + if !person { + ui.add_space(8.0); + ui.label(egui::RichText::new(automation_asked_note()).strong()); + } ui.add_space(16.0); let mut action = DialogAction::None; let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && pending.shown.ready() { + if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && person && pending.shown.ready() { action = DialogAction::Confirm; } pending.shown.released(ui); @@ -472,9 +506,12 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> } /// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the -/// dialog stays open. Before the question has been shown for [`APPROVAL_DELAY`] it installs nothing -/// and the dialog stays open. → `{pending}`. +/// dialog stays open, and so is any OK of a question automation asked. Before the question has been +/// shown for [`APPROVAL_DELAY`] it installs nothing and the dialog stays open. → `{pending}`. fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + if app.pending_plugin_install.as_ref().is_some_and(|p| p.origin == InstallOrigin::Automation) { + return Err("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.".into()); + } if app.automation_is_acting() { return Err("Only the person at the keyboard can approve installing a plug-in".into()); } diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index a8a9f42449..3ad798d998 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -153,8 +153,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Print presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 3d0cb14421..5dfb886044 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -7,8 +7,13 @@ //! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). //! Native and `.ai` files show their save options (VectorCraft Options after Save As): each //! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, -//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. A native file's Version is a -//! menu of the versions VectorCraft writes (`version`, named by the releases that read each). A +//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. EPS shows Use Artboards +//! (`useArtboards`, off by default: the bounding box fits the visible art) with the same `all` or +//! `range` under it, then the options Export's EPS Options shows: Preview, Overprints, Flattener +//! preset and PostScript are menus that write the option's value (`previewFormat: "tiffColor"`, +//! `overprints: "preserve"`, `flattenerPreset: "medium"` or a saved preset's name, `level: 3`). +//! A native file's Version is a menu of the versions VectorCraft writes +//! (`version`, named by the releases that read each). A //! `.ai` file's Version comes first: PDF-compatible, or legacy version 7 (`aiVersion: "7"`, a file //! per artboard), which takes only Transparent Images (`transparentImages`), Hidden Objects //! (`hiddenObjects`: a hidden layer, omitted or made visible), Text (`outlineText`: editable or @@ -18,7 +23,9 @@ use std::sync::OnceLock; use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::FlattenOptions; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode, TEMPLATE_FORMATS}; +use vectorcraft_eps::{Level, Overprint, Preview}; use super::{DialogResult, DialogSpec, form}; use crate::state::Dialog; @@ -76,50 +83,130 @@ fn separates(f: &Format) -> bool { f.options.iter().any(|o| o.name == "separateArtboards") } +/// The checkbox that `f`'s All or Range shows under: Save each artboard to a separate file, or +/// for EPS Use Artboards (off: the visible art's bounds). +fn board_switch(f: &Format) -> Option<&'static str> { + if separates(f) { + Some("separateArtboards") + } else if f.id == "eps" { + Some("useArtboards") + } else { + None + } +} + /// The options the dialog shows: arrays and objects are for agents, as is a native file's /// `precision`, and a page range replaces the single artboard choice; none for a format with its /// own dialog. A `.ait` template has no Version: Save as Template writes it PDF-compatible only. fn shown(f: &Format) -> impl Iterator { let range = f.options.iter().any(|o| o.name == "range"); - let separate = separates(f); + let switched = board_switch(f).is_some(); let options = if own_dialog(f) { &[][..] } else { f.options }; options.iter().filter(move |o| { !(matches!(o.ty, "array" | "object") || o.name == "precision" || (range && o.name == "artboard") - || (separate && o.name == "range") + || (switched && o.name == "range") || (matches!(f.id, "ai" | "ait") && o.name == "useArtboards") - // EPS Save keeps a page and the complete editing document; cropped and - // selected-only output belongs to Export, never this save dialog. - || (f.id == "eps" && matches!(o.name, "useArtboards" | "selectedOnly")) + // EPS Save writes all the artwork: selected-only output belongs to Export. + || (f.id == "eps" && o.name == "selectedOnly") || (f.id == "ait" && o.name == "aiVersion")) }) } -/// An option's label: the save options as the reference app's options dialog names them, the -/// others from their names. +/// An option's label: its written label, or for an option without one, its name split into words. fn label(o: &FormatOption) -> String { - match o.name { + written_label(o).unwrap_or_else(|| form::humanize(o.name)) +} + +/// The written label of option `o`, translated. +fn written_label(o: &FormatOption) -> Option { + Some(match o.name { "separateArtboards" => tl!("Save each artboard to a separate file").into(), - "includeLinked" => tl!("Include Linked Files").into(), + "useArtboards" => tl!("Use Artboards").into(), + "includeLinked" | "includeLinkedFiles" => tl!("Include Linked Files").into(), "embedProfiles" => tl!("Embed ICC Profiles").into(), "pdfCompatible" => tl!("Create PDF-Compatible File").into(), "compress" => tl!("Use Compression").into(), "editingData" => tl!("Include Editing Data for Other Apps").into(), - _ => form::humanize(o.name), - } + "version" | "aiVersion" => tl!("Version:").into(), + "transparentImages" => tl!("Transparent Images:").into(), + "hiddenObjects" => tl!("Hidden Objects:").into(), + "outlineText" => tl!("Text:").into(), + "preview" => tl!("Preview").into(), + "thumbnails" => tl!("Include Document Thumbnails").into(), + "previewFormat" => tl!("Preview:").into(), + "transparentPreview" => tl!("Transparent").into(), + "overprints" => tl!("Overprints:").into(), + "flattenerPreset" => tl!("Flattener preset:").into(), + "embedFonts" => tl!("Embed Fonts (for other applications)").into(), + "cmykPostScript" => tl!("Include CMYK PostScript in RGB Files").into(), + "compatibleGradients" => tl!("Compatible Gradient and Gradient Mesh Printing").into(), + "level" => tl!("PostScript:").into(), + _ => return None, + }) } -/// Every artboard (the All choice under Save each artboard to a separate file): `all` when set, -/// else no range given. +/// The values of an option that takes one of a fixed set, each with the name its menu shows, and +/// `at`, the entry the option's value picks as the save reads it (none when the save would reject +/// the value). The first `builtins` names are VectorCraft's own (translated where they are shown); +/// the others are names the user gave (saved flattener presets), shown as they are. +struct Choices { + values: Vec, + names: Vec, + builtins: usize, + at: Option, +} + +/// The choices of `f`'s option `o`, whose value is `value`, when it takes one of a fixed set of +/// values: EPS's preview format, overprints, PostScript level and flattener preset (the built-in +/// presets by id, then the saved ones by name). +fn choices(app: &VectorcraftApp, f: &Format, o: &FormatOption, value: &Value) -> Option { + if f.id != "eps" { + return None; + } + let text = value.as_str().map_or_else(|| value.to_string(), str::to_string); + let (pairs, builtins, at): (Vec<(Value, String)>, Option, Option) = match o.name { + "previewFormat" => ( + Preview::ALL.iter().map(|p| (json!(p.id()), p.label().to_string())).collect(), + None, + Preview::from_id(&text).and_then(|p| Preview::ALL.iter().position(|q| *q == p)), + ), + "overprints" => ( + Overprint::ALL.iter().map(|p| (json!(p.id()), p.label().to_string())).collect(), + None, + Overprint::from_id(&text).and_then(|p| Overprint::ALL.iter().position(|q| *q == p)), + ), + // The level is a number in the save options. + "level" => ( + Level::ALL.iter().filter_map(|l| Some((json!(l.id().parse::().ok()?), l.label().to_string()))).collect(), + None, + Level::from_id(&text).and_then(|l| Level::ALL.iter().position(|q| *q == l)), + ), + "flattenerPreset" => { + let presets = app.session.flattener_presets(); + let id = |name: &str| FlattenOptions::PRESETS.into_iter().find(|id| FlattenOptions::preset_label(id) == Some(name)); + let pairs = presets.iter().map(|p| (id(&p.name).map_or_else(|| json!(p.name), |id| json!(id)), p.name.clone())).collect(); + let at = app.session.flattener_preset(&text).and_then(|p| presets.iter().position(|q| q.name == p.name)); + (pairs, Some(FlattenOptions::builtin_presets().len()), at) + } + _ => return None, + }; + let builtins = builtins.unwrap_or(pairs.len()); + let (values, names): (Vec, Vec) = pairs.into_iter().unzip(); + Some(Choices { values, names, builtins, at }) +} + +/// Every artboard (the All choice under Save each artboard to a separate file or Use Artboards): +/// `all` when set, else no range given. fn all_artboards(d: &Dialog) -> bool { d.fields.get("all").and_then(Value::as_bool).unwrap_or_else(|| d.str("range").trim().is_empty()) } -/// All or Range, under Save each artboard to a separate file (enabled while it is on). -fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) { +/// All or Range, under the checkbox `switch` (enabled while it is on). +fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog, switch: &str) { ui.label(""); - ui.add_enabled_ui(d.bool("separateArtboards"), |ui| { + ui.add_enabled_ui(d.bool(switch), |ui| { ui.horizontal(|ui| { ui.add_space(22.0); let mut all = all_artboards(d); @@ -167,9 +254,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { // Version first; the legacy version takes only its own options. let legacy = legacy_ai(d); let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect(); - rows.sort_by_key(|o| o.name != "aiVersion"); + // EPS's PostScript level comes last, as in Export's EPS Options. + rows.sort_by_key(|o| (o.name != "aiVersion", f.id == "eps" && o.name == "level")); for o in rows { - option_row(app, ui, d, o); + option_row(app, ui, d, f, o); } }); if legacy_ai(d) && app.session.active().is_some_and(|st| st.doc.artboards.len() > 1) { @@ -330,8 +418,9 @@ fn artboard_picker(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } } -/// One option: a checkbox, an artboard menu, a version menu, a number or a text field. -fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { +/// One option of format `f`: a checkbox, an artboard menu, a version menu, a menu of its +/// [`choices`], a number or a text field. +fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, f: &Format, o: &FormatOption) { let t = Tokens::get(ui.ctx()); let label = label(o); let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); @@ -383,6 +472,40 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma ui.end_row(); return; } + if let Some(c) = choices(app, f, o, &value) { + ui.label(egui::RichText::new(&label).color(t.text_dim)).on_hover_text(o.description); + // A value none of them names shows as it is. + let current = c.at.and_then(|i| c.names.get(i)).cloned().unwrap_or_else(|| value.as_str().map_or_else(|| value.to_string(), str::to_string)); + let names: Vec<&str> = c.names.iter().map(String::as_str).collect(); + if let Some(i) = super::mixed_dropdown(ui, ("save-option", o.name), ¤t, &names, 254.0, |k| k < c.builtins) { + choose(d, o, &c, i); + } + ui.end_row(); + return; + } + if f.id == "eps" && o.name == "transparentPreview" { + // Only the color preview can keep transparency. + ui.label(""); + let color = Preview::from_id(&d.str("previewFormat")) == Some(Preview::TiffColor); + let transparent = value.as_bool().unwrap_or(true); + ui.horizontal(|ui| { + for (name, on) in [(tl!("Transparent"), true), (tl!("Opaque"), false)] { + if widgets::radio(ui, name, color && transparent == on, color) { + d.fields.insert(o.name.into(), json!(on)); + } + ui.add_space(12.0); + } + }); + ui.end_row(); + return; + } + if f.id == "eps" && o.name == "embedFonts" { + // Type is written as glyph outlines, which need no fonts. + ui.label(""); + widgets::check_tip(ui, &label, true, false, o.description); + ui.end_row(); + return; + } if o.ty == "boolean" { ui.label(""); let on = value.as_bool().unwrap_or(false); @@ -392,8 +515,8 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma d.fields.insert(o.name.into(), json!(!on)); } ui.end_row(); - if o.name == "separateArtboards" { - artboard_range(ui, d); + if matches!(o.name, "separateArtboards" | "useArtboards") { + artboard_range(ui, d, o.name); } return; } @@ -421,6 +544,13 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma ui.end_row(); } +/// Option `o` set to the value of entry `i` of its menu `c`. +fn choose(d: &mut Dialog, o: &FormatOption, c: &Choices, i: usize) { + if let Some(v) = c.values.get(i) { + d.fields.insert(o.name.into(), v.clone()); + } +} + /// The native versions VectorCraft writes, newest first, each with the first release that reads /// it (the format version each release tag writes: 2 in 0.1, 3 from 0.2, 4 from 0.8). const NATIVE_VERSIONS: [(u32, &str); 4] = [(4, "0.8"), (3, "0.2"), (2, "0.1"), (1, "0.1")]; @@ -505,8 +635,15 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { if !boards.iter().all(|b| *b) { options.insert("range".into(), json!(super::export_for_screens::range_text(&boards))); } - } else if separates(f) { - if !d.bool("separateArtboards") || all_artboards(d) { + } else if let Some(switch) = board_switch(f) { + if switch == "useArtboards" { + // All or Range alone names the artboards: an artboard remembered from an earlier + // save would otherwise turn Use Artboards on, or narrow All to itself. + options.remove("artboard"); + options.remove("artboards"); + options.insert(switch.into(), json!(d.bool(switch))); + } + if !d.bool(switch) || all_artboards(d) { options.remove("range"); } else { // A bad range keeps the dialog open. diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 9590799687..968d79568c 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -346,19 +346,213 @@ fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); } +/// The `%%BoundingBox` line of an EPS file. +fn bounding_box(eps: &[u8]) -> String { + let ps = std::str::from_utf8(vectorcraft_eps::sections(eps).unwrap().0).unwrap(); + ps.lines().find(|l| l.starts_with("%%BoundingBox:")).expect("a bounding box").to_string() +} + #[test] -fn eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches() { +fn eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard() { let (mut app, written, _) = desktop("art.eps"); app.run("file.saveAs", json!({})).unwrap(); let names: Vec<_> = shown(fileio::format("eps").unwrap()).map(|option| option.name).collect(); - assert!(!names.contains(&"useArtboards") && !names.contains(&"selectedOnly")); + assert!(names.contains(&"useArtboards") && !names.contains(&"selectedOnly") && !names.contains(&"range"), "{names:?}"); assert!(names.contains(&"previewFormat")); + // EPS alone gains the row: the other save formats keep their own checkbox or none. + for id in SAVE_FORMATS.iter().chain(TEMPLATE_FORMATS).filter(|id| **id != "eps") { + let f = fileio::format(id).unwrap(); + assert!(!shown(f).any(|o| o.name == "useArtboards") && board_switch(f) != Some("useArtboards"), "{id}"); + } + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + assert!(text.contains("Use Artboards") && text.contains("All") && text.contains("Range:"), "{text}"); + assert!(!dialog(&app).bool("useArtboards"), "Use Artboards starts off"); super::super::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); - let saved = written.borrow(); - assert_eq!(saved[0].0, "art.eps"); - assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + { + let saved = written.borrow(); + assert_eq!(saved[0].0, "art.eps"); + assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + // The box Export writes of the visible art, not the artboard. + let exported = app.session.execute("document.export", &json!({"format": "eps"})).unwrap(); + let exported = vectorcraft_format::base64_decode(exported["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(bounding_box(&saved[0].1), bounding_box(&exported)); + assert_ne!(bounding_box(&saved[0].1), "%%BoundingBox: 0 0 100 80"); + } assert_eq!(app.session.active().unwrap().format, "eps"); + // Checked, the artboard is the page, and an artboard remembered from an earlier save doesn't + // narrow All. + app.run("file.saveAs", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("useArtboards".into(), json!(true)); + d.fields.insert("artboard".into(), json!(3)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + { + let saved = written.borrow(); + assert_eq!(saved.len(), 2, "one artboard: one file"); + assert_eq!(bounding_box(&saved[1].1), "%%BoundingBox: 0 0 100 80"); + } + // A save that named an artboard without Use Artboards (as document.save {artboard} does) had + // it on: the dialog shows it on, on that artboard, and OK writes the artboard again. + let st = app.session.active_mut().unwrap(); + st.save_options.remove("useArtboards"); + st.save_options.insert("artboard".into(), json!(0)); + app.run("file.saveAs", json!({})).unwrap(); + assert!(dialog(&app).bool("useArtboards")); + assert_eq!(dialog(&app).str("range"), "1"); + super::super::confirm(&mut app).unwrap(); + let saved = written.borrow(); + assert_eq!(saved.len(), 3); + assert_eq!(bounding_box(&saved[2].1), "%%BoundingBox: 0 0 100 80"); +} + +/// Save As › EPS shows the options of Export's EPS Options: Preview, Overprints, Flattener preset +/// and PostScript as menus of readable names (not fields holding `tiffColor`, `preserve` and +/// `medium`), the checkboxes under written labels, and the PostScript level last. +#[test] +fn eps_save_options_are_menus_with_readable_names() { + let (mut app, _, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + let lines: Vec<&str> = text.lines().collect(); + for shown in [ + "Preview:", + "TIFF (8-bit Color)", + "Transparent", + "Opaque", + "Overprints:", + "Preserve", + "Flattener preset:", + "Medium Resolution", + "Embed Fonts (for other applications)", + "Include Linked Files", + "Include Document Thumbnails", + "Include CMYK PostScript in RGB Files", + "Compatible Gradient and Gradient Mesh Printing", + "PostScript:", + "Language Level 3", + ] { + assert!(lines.contains(&shown), "{shown:?} in {text}"); + } + for id in ["tiffColor", "preserve", "medium", "Cmyk Post Script", "Preview Format:", "Level:", "Thumbnails"] { + assert!(!lines.contains(&id), "{id:?} in {text}"); + } + let at = |s: &str| lines.iter().position(|l| *l == s).unwrap(); + assert!(at("PostScript:") > at("Compatible Gradient and Gradient Mesh Printing"), "{text}"); +} + +/// Every option the save options dialog shows, in every format it serves, has a written label: +/// none is painted as its name split into words. +#[test] +fn save_options_have_written_labels() { + // The web's Save As and Save as Template dialogs show the options of every format they pick. + let formats = SAVE_FORMATS.iter().map(|id| (*id, "file.saveAs")).chain(TEMPLATE_FORMATS.iter().map(|id| (*id, "file.saveAsTemplate"))); + for (id, command) in formats { + let (mut app, _) = web(); + app.run(command, json!({})).unwrap(); + let mut d = app.ui.dialog.take().unwrap(); + switch_format(&mut app, &mut d, id); + app.ui.dialog = Some(d); + assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("format").as_str()), (KIND, id)); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + let f = fileio::format(id).unwrap(); + for o in shown(f) { + let written = written_label(o); + assert!(written.is_some(), "{id}: {} has no written label", o.name); + let split = form::humanize(o.name); + let split = split.trim_end_matches(':'); + assert!( + written.as_deref().map(|w| w.trim_end_matches(':')) == Some(split) || !text.lines().any(|l| l == split), + "{id}: {split:?} in {text}" + ); + } + } +} + +/// A pick from an EPS menu saves the option's value: the id, the level as a number, a saved +/// flattener preset by its name. The file is written with them. +#[test] +fn picking_eps_menu_items_saves_their_values() { + let (mut app, written, _) = desktop("art.eps"); + app.session.prefs.flattener_presets.push(vectorcraft_engine::cmd::FlattenerPreset { + name: "Mine".into(), + options: vectorcraft_engine::cmd::FlattenOptions::preset("low").unwrap(), + }); + app.run("file.saveAs", json!({})).unwrap(); + let f = fileio::format("eps").unwrap(); + let mut d = app.ui.dialog.take().unwrap(); + for (name, pick, value) in [ + ("previewFormat", "None", json!("none")), + ("overprints", "Discard", json!("discard")), + ("flattenerPreset", "Mine", json!("Mine")), + ("level", "Language Level 2", json!(2)), + ] { + let o = f.options.iter().find(|o| o.name == name).unwrap(); + let c = choices(&app, f, o, &Value::Null).unwrap_or_else(|| panic!("{name} is a menu")); + let i = c.names.iter().position(|n| n == pick).unwrap_or_else(|| panic!("{pick} in {name}: {:?}", c.names)); + choose(&mut d, o, &c, i); + assert_eq!(d.fields[name], value, "{name}"); + assert_eq!(choices(&app, f, o, &value).unwrap().at, Some(i), "{name}: the menu shows the pick"); + } + // The built-in presets come first, by id. A value picks the entry the save reads it as: a + // preset by id or name in any case, a level by its digits, a preview or overprint by id only. + let at = |name: &str, value: Value| { + let o = f.options.iter().find(|o| o.name == name).unwrap(); + choices(&app, f, o, &value).unwrap().at + }; + let o = f.options.iter().find(|o| o.name == "flattenerPreset").unwrap(); + let c = choices(&app, f, o, &Value::Null).unwrap(); + assert_eq!(c.values, [json!("high"), json!("medium"), json!("low"), json!("Mine")]); + assert_eq!((c.builtins, c.at), (3, None)); + assert_eq!( + (at("flattenerPreset", json!("MEDIUM RESOLUTION")), at("flattenerPreset", json!("mine")), at("flattenerPreset", json!("Other"))), + (Some(1), Some(3), None) + ); + assert_eq!((at("level", json!("level2")), at("level", json!(3)), at("level", json!(4))), (Some(0), Some(1), None)); + assert_eq!((at("previewFormat", json!("TIFFBW")), at("previewFormat", json!("TIFF (8-bit Color)"))), (Some(1), None)); + assert_eq!((at("overprints", json!("Discard")), at("overprints", json!("keep"))), (Some(1), None)); + app.ui.dialog = Some(d); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "saved: {}", app.ui.status); + let saved = &app.session.active().unwrap().save_options; + for (name, value) in [("previewFormat", json!("none")), ("overprints", json!("discard")), ("flattenerPreset", json!("Mine")), ("level", json!(2))] + { + assert_eq!(saved[name], value, "{name}"); + } + let bytes = written.borrow()[0].1.clone(); + let (ps, preview) = vectorcraft_eps::sections(&bytes).unwrap(); + assert!(preview.is_none(), "no preview"); + assert!(String::from_utf8_lossy(ps).contains("%%LanguageLevel: 2")); +} + +/// Save As › EPS: Transparent and Opaque apply to the color TIFF preview only, and are disabled +/// with any other preview. Embed Fonts shows checked and disabled. A flattener preset that no +/// entry of the menu names shows as it is. +#[test] +fn eps_save_transparency_follows_the_color_preview() { + use crate::widgets::accessibility_tests::{named, role_and_state, tree}; + use egui::accesskit::Toggled; + let (mut app, _, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let set = |app: &mut VectorcraftApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + set(&mut app, "previewFormat", json!("tiffColor")); + set(&mut app, "transparentPreview", json!(false)); + let t = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert!(!named(&t, "Transparent").is_disabled() && !named(&t, "Opaque").is_disabled()); + assert_eq!((role_and_state(&t, "Transparent").1, role_and_state(&t, "Opaque").1), (Some(Toggled::False), Some(Toggled::True))); + let fonts = named(&t, "Embed Fonts (for other applications)"); + assert_eq!((fonts.is_disabled(), fonts.toggled()), (true, Some(Toggled::True))); + for preview in ["none", "tiffBw"] { + set(&mut app, "previewFormat", json!(preview)); + let t = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert!(named(&t, "Transparent").is_disabled() && named(&t, "Opaque").is_disabled(), "{preview}"); + } + set(&mut app, "flattenerPreset", json!("Gone")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + assert!(text.lines().any(|l| l == "Gone"), "{text}"); } /// #4931: a native file's Version is a menu of the versions VectorCraft writes, the current one diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 22429b1649..775562fa8f 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -50,13 +50,15 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { frame(&mut app); let d = app.ui.dialog.as_ref().unwrap(); let fields = |d: &crate::state::Dialog| { - ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText", "includeImages", "includeHatches"] + .map(|k| d.str(k)) + .join(" ") }; - assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true false false"); // Any change to a field the preset sets shows Custom, and keeps the other fields. set(&mut app, "colors", json!("true")); frame(&mut app); - assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true false false"); set(&mut app, "colors", json!("256")); frame(&mut app); assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); @@ -70,12 +72,12 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); set(&mut app, "dxfPreset", json!("custom")); frame(&mut app); - assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false true true"); // A preset the command gives sets the fields it does not give. app.ui.dialog = None; app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true false false"); // A preset set and confirmed without drawing (the control channel) sets its fields too. set(&mut app, "dxfPreset", json!("cnc")); confirm(&mut app).unwrap(); @@ -84,7 +86,7 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); let d = app.ui.dialog.as_mut().unwrap(); set_field(d, "dxfPreset".into(), json!("cnc")); - assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true false false"); set_field(d, "unit".into(), json!("Inches")); assert_eq!(d.str("dxfPreset"), "custom"); // Setting the preset again sets its fields again. @@ -94,12 +96,51 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { // Setting Custom while Custom is shown still puts back the defaults. let mut reset = d.clone(); set_field(&mut reset, "dxfPreset".into(), json!("custom")); - assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false true true"); confirm(&mut app).unwrap(); let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); } +#[test] +fn include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom() { + let (mut app, written) = app(1); + let shown = |app: &VectorcraftApp| { + let d = app.ui.dialog.as_ref().unwrap(); + (d.str("dxfPreset"), d.bool("includeImages"), d.bool("includeHatches")) + }; + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, true)); + confirm(&mut app).unwrap(); + assert!(text(&written.borrow()[0].1).contains("\nHATCH\n"), "Custom keeps the fill's hatch"); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/b.dxf"})).unwrap(); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + assert_eq!(shown(&app), ("cnc".into(), false, false)); + // Either checkbox changed shows Custom; both back as the preset sets them show CNC / CAM. + set(&mut app, "includeHatches", json!(true)); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), false, true)); + set(&mut app, "includeHatches", json!(false)); + set(&mut app, "includeImages", json!(true)); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, false)); + set(&mut app, "includeImages", json!(false)); + frame(&mut app); + assert_eq!(shown(&app).0, "cnc"); + confirm(&mut app).unwrap(); + assert!(!text(&written.borrow()[1].1).contains("\nHATCH\n"), "CNC / CAM writes the fill's outline alone"); + let remembered = ["includeImages", "includeHatches"].map(|k| app.ui.dxf_options[k].as_bool()); + assert_eq!(remembered, [Some(false), Some(false)]); + // The next DXF Options start from them, and Custom turns both back on. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(shown(&app), ("cnc".into(), false, false)); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, true)); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 891fb586be..93bb5dec75 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -180,6 +180,15 @@ fn a_bad_range_keeps_export_as_open() { assert!(written.borrow().is_empty()); } +/// An emptied Range field shows an example of the artboard numbers it takes, as Save As's does. +#[test] +fn an_empty_export_as_range_shows_an_example() { + let (mut app, _) = app(2); + app.run("file.exportAs", json!({"useArtboards": true, "range": ""})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.lines().any(|l| l == "1-3, 5"), "{text}"); +} + #[test] fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() { let (mut app, written) = app(2); diff --git a/crates/ui-egui/src/hang_beacon.rs b/crates/ui-egui/src/hang_beacon.rs new file mode 100644 index 0000000000..e47dbfc79c --- /dev/null +++ b/crates/ui-egui/src/hang_beacon.rs @@ -0,0 +1,674 @@ +//! Where the interface thread is in its frame, for the desktop app's hang report (the app's +//! `hang_watch` module). +//! +//! The interface thread marks each stage of a frame (the host's input hook, the app's logic, the +//! interface, and the hand-off to the renderer), a system clipboard call in progress, whether any +//! window's pass had input ([`watch_input`]), and each command it runs. Another thread reads the +//! marks without waiting for the interface thread: the stage and the activity are atomics, and the +//! recent commands and frame times are read with `try_lock`. Marking costs a few atomic stores a +//! frame and one short lock per command and per frame. The web build marks the same way, and +//! nothing reads the marks there. +//! +//! Commands are kept by id only: no parameters, file names or document contents. A command run +//! again moves to the end of the list instead of taking another place, so a panel that runs a +//! query every frame does not push out the commands before it. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use vectorcraft_engine::cmd::clipboard::Flavour; + +use crate::sysclip::{ClipboardRead, SystemClipboard}; + +/// How many different command ids [`Beacon::recent_commands`] keeps. +pub const RECENT_COMMANDS: usize = 8; +/// How many frames [`Beacon::recent_frames`] keeps. +pub const RECENT_FRAMES: usize = 16; +/// The longest command id [`Beacon::command`] keeps as it is. +pub const MAX_COMMAND_ID: usize = 64; + +/// The marks the desktop app's interface thread writes. +pub static BEACON: Beacon = Beacon::new(); + +/// The part of a frame the interface thread is in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Stage { + /// Outside the app's own frame code: waiting for input or a repaint, or a frame without input + /// handed to the renderer. + Idle = 0, + /// The host's input hook. + Input = 1, + /// The app's logic: background results, timers, the control channel. + Logic = 2, + /// The interface: input handling, commands, panels and dialogs. + Interface = 3, + /// A frame that had input was handed to the renderer. Drawing and presenting it follow, and + /// then the next frame, which input asks for at once. + Drawing = 4, +} + +impl Stage { + fn from_u8(n: u8) -> Stage { + match n { + 1 => Stage::Input, + 2 => Stage::Logic, + 3 => Stage::Interface, + 4 => Stage::Drawing, + _ => Stage::Idle, + } + } + + /// Whether the interface thread is expected to finish this stage soon. + pub fn busy(self) -> bool { + self != Stage::Idle + } + + /// The stage for a report, in English. + pub fn describe(self) -> &'static str { + match self { + Stage::Idle => "between frames", + Stage::Input => "reading the window's input", + Stage::Logic => "running the app's logic (background results, timers, the control channel)", + Stage::Interface => "running the interface (input handling, commands, panels and dialogs)", + Stage::Drawing => { + "after a frame with input went to the renderer: drawing or presenting it, or waiting for the next frame, which the system may hold back while the window is hidden" + } + } + } +} + +/// What the interface thread is waiting on inside a stage, where the app knows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Activity { + None = 0, + /// A read of the system clipboard, or a check of what it holds. + ClipboardRead = 1, + /// A write to the system clipboard. + ClipboardWrite = 2, + /// A system file dialog shown in line, which waits for the user and keeps the window + /// answering the system ([`Sample::busy`]). + Dialog = 3, + /// A menu of the system menu bar is open, which waits for the user ([`Sample::busy`]). The + /// host sets it on the sample it reads; the interface thread does not mark it. + Menu = 4, +} + +impl Activity { + fn from_u8(n: u8) -> Activity { + match n { + 1 => Activity::ClipboardRead, + 2 => Activity::ClipboardWrite, + 3 => Activity::Dialog, + 4 => Activity::Menu, + _ => Activity::None, + } + } + + /// Whether the activity waits for the user, so a long one is not a stall. + pub fn waits_for_user(self) -> bool { + matches!(self, Activity::Dialog | Activity::Menu) + } + + /// The activity for a report, in English; `None` when there is none. + pub fn describe(self) -> Option<&'static str> { + match self { + Activity::None => None, + Activity::ClipboardRead => Some("reading the system clipboard"), + Activity::ClipboardWrite => Some("writing to the system clipboard"), + Activity::Dialog => Some("a system file dialog, waiting for the user"), + Activity::Menu => Some("a menu of the system menu bar, waiting for the user"), + } + } +} + +/// What the interface thread was doing when it was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sample { + /// Counts every mark: the same value in two samples means the thread made no mark between them. + pub step: u64, + pub stage: Stage, + pub activity: Activity, +} + +impl Sample { + /// Whether the interface thread is expected to move on soon: inside a stage, and not showing a + /// system dialog or menu that waits for the user. + pub fn busy(&self) -> bool { + self.stage.busy() && !self.activity.waits_for_user() + } +} + +/// One frame's times, in milliseconds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameTime { + /// The app's own work: from the input hook to the end of the interface. + pub work_ms: f32, + /// The renderer's measure of the frame before: the app's work and drawing, without waiting for + /// the display. `None` when the host has none. + pub previous_total_ms: Option, +} + +/// The marks: see the module documentation. +pub struct Beacon { + step: AtomicU64, + stage: AtomicU8, + activity: AtomicU8, + documents: AtomicUsize, + /// A pass of some window had input since [`Beacon::take_input`] last ran. + input: AtomicBool, + /// The windows a pass handed to the system since [`Beacon::take_handed_off`] last ran. Only the + /// interface thread uses it. + handed_off: Mutex>, + commands: Mutex>, + frames: Mutex>, +} + +impl Default for Beacon { + fn default() -> Self { + Self::new() + } +} + +impl Beacon { + pub const fn new() -> Beacon { + Beacon { + step: AtomicU64::new(0), + stage: AtomicU8::new(Stage::Idle as u8), + activity: AtomicU8::new(Activity::None as u8), + documents: AtomicUsize::new(0), + input: AtomicBool::new(false), + handed_off: Mutex::new(Vec::new()), + commands: Mutex::new(VecDeque::new()), + frames: Mutex::new(VecDeque::new()), + } + } + + fn bump(&self) { + self.step.fetch_add(1, Ordering::Release); + } + + /// The interface thread enters `stage`. + pub fn enter(&self, stage: Stage) { + self.stage.store(stage as u8, Ordering::Relaxed); + self.bump(); + } + + /// The interface thread enters `stage` of a frame, timed whatever the activity around it, so a + /// frame the system runs while a file dialog waits for the user is timed too. The activity + /// before it applies again when the guard is dropped. + pub fn enter_timed(&self, stage: Stage) -> ActivityGuard<'_> { + self.enter(stage); + self.activity(Activity::None) + } + + /// The interface thread starts `activity`; it ends when the guard is dropped, and the activity + /// before it applies again. + pub fn activity(&self, activity: Activity) -> ActivityGuard<'_> { + let before = Activity::from_u8(self.activity.swap(activity as u8, Ordering::Relaxed)); + self.bump(); + ActivityGuard { beacon: self, before } + } + + /// The interface thread runs the command `id`. An id that is not a command id's shape (longer + /// than [`MAX_COMMAND_ID`] characters, or with characters other than ASCII letters, digits, `.`, + /// `_` and `-`) is kept as `(other)`: ids can come from saved files and the control channel. An + /// id already in the list moves to its end. + pub fn command(&self, id: &str) { + self.bump(); + let shaped = id.len() <= MAX_COMMAND_ID && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + let kept = if shaped { id } else { "(other)" }; + let mut commands = self.commands.lock().unwrap_or_else(PoisonError::into_inner); + if commands.back().is_some_and(|last| last == kept) { + return; + } + let entry = match commands.iter().position(|c| c == kept) { + Some(i) => commands.remove(i).unwrap_or_else(|| kept.to_string()), + None => { + if commands.len() == RECENT_COMMANDS { + commands.pop_front(); + } + kept.to_string() + } + }; + commands.push_back(entry); + } + + /// A pass of some window had input ([`watch_input`]). + pub fn input(&self) { + self.input.store(true, Ordering::Relaxed); + } + + /// Whether a pass of some window had input since the last call. + pub fn take_input(&self) -> bool { + self.input.swap(false, Ordering::Relaxed) + } + + /// A pass of window `id` sent it commands that hand it to the system ([`hands_off`], + /// [`watch_input`]). + pub fn hand_off(&self, id: egui::ViewportId) { + let mut windows = self.handed_off.lock().unwrap_or_else(PoisonError::into_inner); + if !windows.contains(&id) { + windows.push(id); + } + } + + /// The windows a pass handed to the system since the last call. + pub fn take_handed_off(&self) -> Vec { + std::mem::take(&mut *self.handed_off.lock().unwrap_or_else(PoisonError::into_inner)) + } + + /// A frame ended with `time`, with `documents` documents open. + pub fn frame(&self, time: FrameTime, documents: usize) { + self.documents.store(documents, Ordering::Relaxed); + let mut frames = self.frames.lock().unwrap_or_else(PoisonError::into_inner); + if frames.len() == RECENT_FRAMES { + frames.pop_front(); + } + frames.push_back(time); + } + + /// The current marks. + pub fn sample(&self) -> Sample { + let step = self.step.load(Ordering::Acquire); + Sample { step, stage: Stage::from_u8(self.stage.load(Ordering::Relaxed)), activity: Activity::from_u8(self.activity.load(Ordering::Relaxed)) } + } + + /// The ids of the last [`RECENT_COMMANDS`] commands, oldest first; `None` while the interface + /// thread is writing them. + pub fn recent_commands(&self) -> Option> { + let commands = match self.commands.try_lock() { + Ok(c) => c, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(commands.iter().cloned().collect()) + } + + /// The last [`RECENT_FRAMES`] frames' times, oldest first; `None` while the interface thread is + /// writing them. + pub fn recent_frames(&self) -> Option> { + let frames = match self.frames.try_lock() { + Ok(f) => f, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(frames.iter().copied().collect()) + } + + /// The documents open at the end of the last frame. + pub fn documents(&self) -> usize { + self.documents.load(Ordering::Relaxed) + } +} + +/// Whether a pass's input asks for the next frame at once: events, or files dropped or hovered. +pub fn had_input(raw: &egui::RawInput) -> bool { + !raw.events.is_empty() || !raw.dropped_files.is_empty() || !raw.hovered_files.is_empty() +} + +/// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main +/// window's, after the host's input hook added its own, and those of the windows shown in line +/// (floating panels and documents), whose input does not pass through that hook. Mark as well every +/// pass of a window shown in line that sends its window commands that hand it to the system +/// ([`hands_off`]), before egui passes them on; the main window's are read at the frame's end. +pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) { + ctx.on_begin_pass( + "VectorCraft hang report input", + std::sync::Arc::new(move |ui| { + if ui.ctx().input(|i| had_input(&i.raw)) { + beacon.input(); + } + }), + ); + ctx.on_end_pass( + "VectorCraft hang report hand-off", + std::sync::Arc::new(move |ui| { + if ui.ctx().viewport_id() != egui::ViewportId::ROOT && ui.ctx().viewport(|vp| hands_off(&vp.commands)) { + beacon.hand_off(ui.ctx().viewport_id()); + } + }), + ); +} + +/// Whether `commands` minimize or hide their window, or hand it to the system to move or resize: +/// the system may hold back the next frame until the user is done. +pub fn hands_off(commands: &[egui::ViewportCommand]) -> bool { + use egui::ViewportCommand as C; + commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_))) +} + +/// The window whose end of frame sets the stage ([`after_frame`]): the main window when it is +/// `main_shown`; otherwise a window shown in line (a floating panel or document) that `viewports` +/// (the main window's input) gives as focused and not minimized; otherwise none. +pub fn shown_window(main_shown: bool, viewports: &egui::ViewportIdMap) -> Option { + if main_shown { + return Some(egui::ViewportId::ROOT); + } + viewports.iter().find(|(id, v)| **id != egui::ViewportId::ROOT && v.focused == Some(true) && v.minimized != Some(true)).map(|(id, _)| *id) +} + +/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and a window is +/// `shown` (focused, not minimized or hidden: [`shown_window`]), since input asks for the next +/// frame at once; otherwise [`Stage::Idle`]. A frame that sends that window `commands` that +/// minimize or hide it, or hand it to the system to move or resize ([`hands_off`]), also ends idle. +pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage { + if input && shown && !hands_off(commands) { Stage::Drawing } else { Stage::Idle } +} + +/// Ends an [`Activity`] when dropped ([`Beacon::activity`]). +pub struct ActivityGuard<'a> { + beacon: &'a Beacon, + before: Activity, +} + +impl Drop for ActivityGuard<'_> { + fn drop(&mut self) { + self.beacon.activity.store(self.before as u8, Ordering::Relaxed); + self.beacon.bump(); + } +} + +/// A [`SystemClipboard`] whose calls are marked on [`BEACON`] as clipboard activity. The desktop +/// app wraps the clipboard the interface thread uses; the background probe's own clipboard stays +/// unmarked. +pub struct Marked(pub Box); + +impl SystemClipboard for Marked { + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let _mark = BEACON.activity(Activity::ClipboardWrite); + self.0.write(flavours) + } + + fn holds_ours(&mut self) -> bool { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.holds_ours() + } + + fn read(&mut self, mimes: &[&'static str]) -> Option { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.read(mimes) + } + + fn has(&mut self, mimes: &[&'static str]) -> bool { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.has(mimes) + } + + fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.read_deferred(mimes) + } + + fn change_count(&mut self) -> Option { + let _mark = BEACON.activity(Activity::ClipboardRead); + self.0.change_count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_mark_moves_the_step_and_the_stage_reads_back() { + let b = Beacon::new(); + let first = b.sample(); + assert_eq!((first.stage, first.activity), (Stage::Idle, Activity::None)); + b.enter(Stage::Interface); + let s = b.sample(); + assert!(s.step > first.step); + assert_eq!(s.stage, Stage::Interface); + b.command("edit.copy"); + assert!(b.sample().step > s.step); + b.enter(Stage::Idle); + assert!(!b.sample().stage.busy()); + } + + #[test] + fn an_activity_ends_with_its_guard_and_restores_the_one_before() { + let b = Beacon::new(); + { + let _write = b.activity(Activity::ClipboardWrite); + { + let _read = b.activity(Activity::ClipboardRead); + assert_eq!(b.sample().activity, Activity::ClipboardRead); + } + assert_eq!(b.sample().activity, Activity::ClipboardWrite); + } + assert_eq!(b.sample().activity, Activity::None); + } + + #[test] + fn only_the_last_commands_and_frames_are_kept_oldest_first() { + let b = Beacon::new(); + for n in 0..RECENT_COMMANDS + 3 { + b.command(&format!("cmd.{n}")); + } + let commands = b.recent_commands().unwrap_or_default(); + assert_eq!(commands.len(), RECENT_COMMANDS); + assert_eq!(commands.first().map(String::as_str), Some("cmd.3")); + assert_eq!(commands.last().map(String::as_str), Some("cmd.10")); + for n in 0..RECENT_FRAMES + 2 { + b.frame(FrameTime { work_ms: n as f32, previous_total_ms: None }, 2); + } + let frames = b.recent_frames().unwrap_or_default(); + assert_eq!(frames.len(), RECENT_FRAMES); + assert_eq!(frames.first().map(|f| f.work_ms), Some(2.0)); + assert_eq!(b.documents(), 2); + } + + #[test] + fn a_command_run_every_frame_does_not_push_out_the_ones_before() { + let b = Beacon::new(); + for n in 0..RECENT_COMMANDS - 2 { + b.command(&format!("cmd.{n}")); + } + // Two panels that each run a query every frame. + for _ in 0..100 { + b.command("symbol.list"); + b.command("swatch.list"); + } + b.command("cmd.0"); + let commands = b.recent_commands().unwrap_or_default(); + let want: Vec = ["cmd.1", "cmd.2", "cmd.3", "cmd.4", "cmd.5", "symbol.list", "swatch.list", "cmd.0"].map(String::from).to_vec(); + assert_eq!(commands, want); + } + + #[test] + fn a_frame_with_input_ends_drawing_unless_the_window_is_hidden_or_handed_to_the_system() { + use egui::ViewportCommand as C; + assert_eq!(after_frame(true, true, &[]), Stage::Drawing); + assert_eq!(after_frame(true, true, &[C::Focus]), Stage::Drawing); + assert_eq!(after_frame(false, true, &[]), Stage::Idle); + assert_eq!(after_frame(true, false, &[]), Stage::Idle); + for c in [C::Minimized(true), C::Visible(false), C::StartDrag, C::BeginResize(egui::ResizeDirection::East)] { + assert_eq!(after_frame(true, true, std::slice::from_ref(&c)), Stage::Idle, "{c:?}"); + } + } + + #[test] + fn input_is_events_or_files() { + let mut raw = egui::RawInput::default(); + assert!(!had_input(&raw)); + raw.hovered_files.push(egui::HoveredFile::default()); + assert!(had_input(&raw)); + #[derive(Debug)] + struct Dropped; + impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + std::path::Path::new("a.svg") + } + fn bytes(&self) -> Result, String> { + Ok(Vec::new()) + } + } + let mut raw = egui::RawInput::default(); + raw.dropped_files.push(std::sync::Arc::new(Dropped)); + assert!(had_input(&raw)); + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::PointerGone); + assert!(had_input(&raw)); + } + + #[test] + fn input_in_a_window_shown_in_line_is_marked() { + static B: Beacon = Beacon::new(); + // The host runs a window shown in line as its own pass, with that window's input. + let child_events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let events = child_events.clone(); + egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { + let id = viewport.ids.this; + let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect(); + let input = egui::RawInput { viewport_id: id, viewports, events: events.take(), ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + watch_input(&B, &ctx); + let child = egui::ViewportId::from_hash_of("floating panel"); + let frame = |main: Vec| { + ctx.run_ui(egui::RawInput { events: main, ..Default::default() }, |ui| { + ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |_, _| {}); + }) + .textures_delta + .clear(); + }; + frame(Vec::new()); + assert!(!B.take_input(), "no input anywhere"); + child_events.replace(vec![egui::Event::PointerGone]); + frame(Vec::new()); + assert!(B.take_input(), "the floating window's input"); + assert!(!B.take_input(), "taken"); + frame(vec![egui::Event::PointerGone]); + assert!(B.take_input(), "the main window's input"); + } + + #[test] + fn a_frame_run_while_a_dialog_waits_is_timed() { + let b = Beacon::new(); + let _dialog = b.activity(Activity::Dialog); + assert!(!b.sample().busy(), "the dialog waits for the user"); + { + let _timed = b.enter_timed(Stage::Interface); + assert!(b.sample().busy(), "a frame inside the dialog's wait"); + b.enter(Stage::Drawing); + } + assert_eq!(b.sample().activity, Activity::Dialog, "the dialog again"); + assert!(!b.sample().busy()); + } + + #[test] + fn a_window_shown_in_line_handed_to_the_system_is_marked() { + static B: Beacon = Beacon::new(); + egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| { + let id = viewport.ids.this; + let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect(); + let input = egui::RawInput { viewport_id: id, viewports, ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + watch_input(&B, &ctx); + let child = egui::ViewportId::from_hash_of("floating panel"); + let frame = |command: Option, main: Option| { + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |ui, _| { + if let Some(c) = command.clone() { + ui.ctx().send_viewport_cmd(c); + } + }); + if let Some(c) = main.clone() { + ui.ctx().send_viewport_cmd(c); + } + }) + .textures_delta + .clear(); + }; + frame(None, None); + assert!(B.take_handed_off().is_empty(), "nothing handed off"); + frame(Some(egui::ViewportCommand::StartDrag), None); + assert_eq!(B.take_handed_off(), [child], "the floating window starts a move"); + assert!(B.take_handed_off().is_empty(), "taken"); + frame(Some(egui::ViewportCommand::Focus), None); + assert!(B.take_handed_off().is_empty(), "another command"); + frame(None, Some(egui::ViewportCommand::StartDrag)); + assert!(B.take_handed_off().is_empty(), "the main window's commands are read at the frame's end"); + } + + #[test] + fn a_focused_window_shown_in_line_is_the_shown_window() { + use egui::{ViewportId, ViewportIdMap, ViewportInfo}; + let child = ViewportId::from_hash_of("document window"); + let info = |focused: bool, minimized: bool| ViewportInfo { focused: Some(focused), minimized: Some(minimized), ..Default::default() }; + let map = |root: ViewportInfo, window: ViewportInfo| -> ViewportIdMap { + [(ViewportId::ROOT, root), (child, window)].into_iter().collect() + }; + assert_eq!(shown_window(true, &map(info(true, false), info(false, false))), Some(ViewportId::ROOT)); + assert_eq!(shown_window(false, &map(info(false, false), info(true, false))), Some(child), "the document window has the focus"); + assert_eq!(shown_window(false, &map(info(false, false), info(true, true))), None, "minimized"); + assert_eq!(shown_window(false, &map(info(false, false), info(false, false))), None, "no window has the focus"); + assert_eq!(shown_window(false, &map(info(true, false), info(false, false))), None, "the main window is not shown"); + } + + #[test] + fn the_marked_clipboard_marks_its_calls() { + type Seen = std::sync::Arc>>; + struct Probe(Seen); + impl Probe { + fn see(&self) { + self.0.lock().unwrap().push(BEACON.sample().activity); + } + } + impl SystemClipboard for Probe { + fn write(&mut self, _: &[Flavour]) -> Result<(), String> { + self.see(); + Ok(()) + } + fn holds_ours(&mut self) -> bool { + self.see(); + false + } + fn read(&mut self, _: &[&'static str]) -> Option { + self.see(); + None + } + fn has(&mut self, _: &[&'static str]) -> bool { + self.see(); + false + } + } + let seen = Seen::default(); + let mut m = Marked(Box::new(Probe(seen.clone()))); + let _ = m.write(&[]); + let _ = m.read(&[]); + let _ = m.has(&[]); + let _ = m.holds_ours(); + assert_eq!(BEACON.sample().activity, Activity::None); + assert_eq!(*seen.lock().unwrap(), vec![Activity::ClipboardWrite, Activity::ClipboardRead, Activity::ClipboardRead, Activity::ClipboardRead]); + } + + #[test] + fn ids_that_are_not_command_ids_are_kept_as_other() { + let b = Beacon::new(); + b.command("file.open"); + b.command("/home/someone/private plan.vectorcraft"); + b.command(&"y".repeat(MAX_COMMAND_ID)); + b.command(&"x".repeat(MAX_COMMAND_ID + 1)); + let commands = b.recent_commands().unwrap_or_default(); + // Both are `(other)`, which moves to the end the second time. + assert_eq!(commands, vec!["file.open".to_string(), "y".repeat(MAX_COMMAND_ID), "(other)".into()]); + } + + #[test] + fn a_reader_does_not_wait_while_the_commands_are_being_written() { + let b = Beacon::new(); + b.command("edit.paste"); + let held = b.commands.lock().unwrap_or_else(PoisonError::into_inner); + assert_eq!(b.recent_commands(), None); + drop(held); + assert_eq!(b.recent_commands(), Some(vec!["edit.paste".to_string()])); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 05756a1af6..57c345505a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2282,6 +2282,7 @@ font-technology Variable Променливи Options for Selection… Опции за селектиране… Options: Опции: Options… Опции… + Organic Fades Органични преливания or more spaces with a tab или повече интервала с табулация Order: Ред: Ordinals Поредни номера @@ -2473,6 +2474,7 @@ font-technology Variable Променливи Presets: Предварителни настройки: Press Натиснете Preview Преглед + Preview: Преглед: Preview All Layers Преглед на всички слоеве Preview in Browser Преглед в браузър Preview in Browser needs the desktop app (the web downloads files instead) Прегледът в браузър изисква настолното приложение (уеб версията изтегля файловете вместо това) @@ -2565,6 +2567,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растерен ефект Raster File Format: Растерен файлов формат: + Include Placed Images Включване на поставените изображения + Include Fill Hatches Включване на щриховките на запълванията Raster Sample Size: Растерен размер на пробата: Raster/Vector Balance: Баланс растер/вектор: Rasterize Растеризиране @@ -2745,6 +2749,10 @@ digits Malayalam Малаялам Replace Font Замяна на шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Заменете го с друг файл или го игнорирайте: неговите изображения ще продължат да показват записания с документа преглед. Replace Spine Замяна на оста + Create Spine Създаване на ос + Create Spine: Създаване на ос: + Always Винаги + Never Никога Replace Symbol Замяна на символ Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт @@ -5889,6 +5897,7 @@ axis Y Axis Y ос Version: {version} Версия: {version} Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация поиска инсталирането на тази приставка. За да я инсталирате, изберете Обект › Приставки › Инсталиране на приставка… сами. File SHA-256: {hash} SHA-256 на файла: {hash} Read the objects it is given Четене на обектите, които получава Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа @@ -6560,6 +6569,11 @@ breakText Glyphs Глифи No importer plug-ins installed Няма инсталирани плъгини за импортиране Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях +# Copy Troubleshooting Info + Copy Troubleshooting Info Копиране на информация за отстраняване на проблеми +@msg Copied the troubleshooting info Информацията за отстраняване на проблеми е копирана +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последната сесия на VectorCraft спря да отговаря, преди да се затвори. Помощ › Копиране на информация за отстраняване на проблеми копира подробностите за доклад за грешка + # Safe Mode Safe Mode… Безопасен режим… Safe Mode Безопасен режим diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a9e705cfac..e28312f750 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -256,6 +256,10 @@ Blend Options… Opcije stapanja… Expand Proširi Replace Spine Zamijeni kičmu + Create Spine Kreiraj kičmu + Create Spine: Kreiraj kičmu: + Always Uvijek + Never Nikad Reverse Spine Obrni kičmu Reverse Front to Back Obrni od naprijed prema nazad Envelope Distort Izobličenje omotnicom @@ -556,6 +560,7 @@ Gaussian Blur… Gaussovo zamućenje… View Prikaz Preview Pregled + Preview: Pregled: Overprint Preview Pregled pretiska Pixel Preview Pregled piksela Trim View Prikaz obrezivanja @@ -826,6 +831,7 @@ Spectrum Gradients Gradijenti spektra Sky Gradients Gradijenti neba Neutral Gradients Neutralni gradijenti + Organic Fades Organski prelazi Duotone Gradients Duoton gradijenti Radial Glows Radijalni sjajevi Save Swatch Library… Sačuvaj biblioteku uzoraka boja… @@ -2360,6 +2366,8 @@ digits Malayalam Malajalam Export Selected Art Only Izvezi samo odabrani crtež Number of Colors: Broj boja: Raster File Format: Format rasterske datoteke: + Include Placed Images Uključi postavljene slike + Include Fill Hatches Uključi šrafure ispuna True colour needs version 2004 or later: the nearest of 256 colours is written. Prava boja zahtijeva verziju 2004 ili noviju: zapisuje se najbliža od 256 boja. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst postaje obrisi, a potezi koje CAD linija ne može nacrtati postaju ispunjeni oblici. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst ostaje tekst, a svaki potez je linija sa svojom debljinom i crticama. @@ -5910,6 +5918,8 @@ axis Y Axis Y osa Version: {version} Verzija: {version} Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane File SHA-256: {hash} SHA-256 datoteke: {hash} Read the objects it is given Čitati objekte koje dobije @@ -6611,6 +6621,11 @@ shape Open Otvoren No importer plug-ins installed Nisu instalirani dodaci za uvoz Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiraj informacije za rješavanje problema +@msg Copied the troubleshooting info Informacije za rješavanje problema kopirane +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Posljednja sesija VectorCrafta prestala je odgovarati prije zatvaranja. Pomoć › Kopiraj informacije za rješavanje problema kopira detalje za prijavu greške + # Safe Mode Safe Mode… Sigurni način rada… Safe Mode Sigurni način rada diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5341805a73..21ac28a103 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2917,6 +2917,7 @@ digits Malayalam Malaiàlam Options for Selection… Opcions per a la selecció… Options: Opcions: Options… Opcions… + Organic Fades Esvaïments orgànics or more spaces with a tab o més espais amb un tabulador Orange Taronja Order: Ordre: @@ -3207,6 +3208,7 @@ digits Malayalam Malaiàlam Press Quality Qualitat d’impressió Pressure Pressió Preview Previsualització + Preview: Previsualització: Preview (1 inch on the page) Previsualització (1 polzada a la pàgina) Preview All Layers Previsualitza totes les capes Preview in Browser Previsualitza al navegador @@ -3308,6 +3310,8 @@ digits Malayalam Malaiàlam Range: Interval: Raster Effects Efectes de ràster Raster File Format: Format de fitxer de ràster: + Include Placed Images Inclou les imatges col·locades + Include Fill Hatches Inclou les trames dels emplenaments Raster Sample Size: Mida de mostra de ràster: Raster/Vector Balance: Equilibri ràster/vector: Rasterize Rasteritza @@ -3494,6 +3498,10 @@ digits Malayalam Malaiàlam Replace Font Reemplaça el tipus de lletra Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplaça’l per un altre fitxer o ignora’l: les seves imatges continuaran mostrant la previsualització desada amb el document. Replace Spine Reemplaça l’espina + Create Spine Crea l’espina + Create Spine: Crea l’espina: + Always Sempre + Never Mai Replace Symbol Reemplaça el símbol Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra @@ -6122,6 +6130,8 @@ digits Malayalam Malaiàlam Version: {version} Versió: {version} Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord File SHA-256: {hash} SHA-256 del fitxer: {hash} Read the objects it is given Llegir els objectes que rep @@ -6184,6 +6194,12 @@ digits Malayalam Malaiàlam @msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra @msg command {name}: {e} ordre {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (línia {line}, columna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` ha de ser una llista d’ordres, cadascuna amb `id` i `label` +@msg command {name} must be an object with an `id` and a `label` l’ordre {name} ha de ser un objecte amb `id` i `label` +@msg missing field `{key}` falta el camp `{key}` +@msg duplicate field `{key}` el camp `{key}` està repetit @msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @@ -6831,6 +6847,11 @@ breakText Glyphs Glifs @msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copia la informació de resolució de problemes +@msg Copied the troubleshooting info S’ha copiat la informació de resolució de problemes +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La darrera sessió del VectorCraft va deixar de respondre abans de tancar-se. Ajuda › Copia la informació de resolució de problemes copia els detalls per a un informe d’error + # Safe Mode Safe Mode… Mode segur… Safe Mode Mode segur diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9d6fd981e6..536e373533 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -262,6 +262,10 @@ Blend Options… Možnosti prolnutí… Expand Rozložit Replace Spine Nahradit páteř + Create Spine Vytvořit páteř + Create Spine: Vytvořit páteř: + Always Vždy + Never Nikdy Reverse Spine Obrátit páteř Reverse Front to Back Obrátit pořadí zepředu dozadu Envelope Distort Deformace obálkou @@ -565,6 +569,7 @@ Gaussian Blur… Gaussovské rozostření… View Zobrazení Preview Náhled + Preview: Náhled: Overprint Preview Náhled přetisků Pixel Preview Náhled obrazových bodů Trim View Oříznuté zobrazení @@ -835,6 +840,7 @@ Spectrum Gradients Spektrální přechody Sky Gradients Přechody oblohy Neutral Gradients Neutrální přechody + Organic Fades Organické přechody Duotone Gradients Dvoutónové přechody Radial Glows Kruhové záře Save Swatch Library… Uložit knihovnu vzorníku… @@ -2389,6 +2395,8 @@ digits Malayalam Malajálamské Export Selected Art Only Exportovat jen vybranou kresbu Number of Colors: Počet barev: Raster File Format: Formát rastrového souboru: + Include Placed Images Zahrnout umístěné obrazy + Include Fill Hatches Zahrnout šrafy výplní True colour needs version 2004 or later: the nearest of 256 colours is written. Plné barvy vyžadují verzi 2004 nebo novější: zapíše se nejbližší z 256 barev. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Text se převede na křivky a tahy, které čára CAD nedokáže vykreslit, se stanou vyplněnými tvary. Type stays text, and every stroke is a line with its lineweight and dashes. Text zůstane textem a každý tah bude čárou s vlastní tloušťkou a čárkováním. @@ -6075,6 +6083,8 @@ axis Y Axis osa Y Version: {version} Verze: {version} Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí File SHA-256: {hash} SHA-256 souboru: {hash} Read the objects it is given Číst objekty, které dostane @@ -6137,6 +6147,12 @@ axis Y Axis osa Y @msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem @msg command {name}: {e} příkaz {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (řádek {line}, sloupec {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` musí být seznam příkazů, každý s `id` a `label` +@msg command {name} must be an object with an `id` and a `label` příkaz {name} musí být objekt s `id` a `label` +@msg missing field `{key}` chybí pole `{key}` +@msg duplicate field `{key}` pole `{key}` je uvedeno dvakrát @msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno @@ -6850,6 +6866,11 @@ breakText Glyphs Glyfy @msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul @msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopírovat informace pro řešení potíží +@msg Copied the troubleshooting info Informace pro řešení potíží zkopírovány +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Poslední relace VectorCraftu přestala odpovídat, než se zavřela. Nápověda › Kopírovat informace pro řešení potíží zkopíruje podrobnosti pro hlášení chyby + # Safe Mode Safe Mode… Nouzový režim… Safe Mode Nouzový režim diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index af95c4119d..64c8e60dad 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -248,6 +248,10 @@ Blend Options… Blandingsindstillinger… Expand Udvid Replace Spine Erstat rygrad + Create Spine Opret rygrad + Create Spine: Opret rygrad: + Always Altid + Never Aldrig Reverse Spine Omvend rygrad Reverse Front to Back Omvend fortil og bagtil Envelope Distort Kuvertforvrængning @@ -520,6 +524,7 @@ Gaussian Blur… Gaussisk sløring… View Vis Preview Forhåndsvisning + Preview: Forhåndsvisning: Overprint Preview Overprint-forhåndsvisning Pixel Preview Pixel-forhåndsvisning Trim View Beskåret visning @@ -772,6 +777,7 @@ Spectrum Gradients Spektrumgradienter Sky Gradients Himmelgradienter Neutral Gradients Neutrale gradienter + Organic Fades Organiske toninger Duotone Gradients Duotonegradienter Radial Glows Radiale gløder Save Swatch Library… Gem bibliotek med farveprøver… @@ -2192,6 +2198,8 @@ digits Malayalam Malayalam Export Selected Art Only Eksportér kun markeret grafik Number of Colors: Antal farver: Raster File Format: Rasterfilformat: + Include Placed Images Medtag placerede billeder + Include Fill Hatches Medtag skraveringer af udfyldninger True colour needs version 2004 or later: the nearest of 256 colours is written. Ægte farve kræver version 2004 eller senere: den nærmeste af 256 farver skrives. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst bliver til omrids, og konturer, som en CAD-linje ikke kan tegne, bliver til udfyldte figurer. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst forbliver tekst, og hver kontur er en linje med sin linjevægt og streger. @@ -5904,6 +5912,7 @@ axis Y Axis Y-akse Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om at installere dette plug-in. Vælg selv Objekt › Plug-ins › Installér plug-in… for at installere det. File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Læse de objekter, den får Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet @@ -6574,6 +6583,11 @@ breakText Glyphs Glyffer No importer plug-ins installed Ingen import-plug-ins installeret Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiér fejlfindingsoplysninger +@msg Copied the troubleshooting info Kopierede fejlfindingsoplysningerne +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den seneste VectorCraft-session holdt op med at svare, før den blev lukket. Hjælp › Kopiér fejlfindingsoplysninger kopierer detaljerne til en fejlrapport + # Safe Mode Safe Mode… Sikker tilstand… Safe Mode Sikker tilstand diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 55bc587b64..c5604d4a6d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -808,6 +808,14 @@ font-technology Variable Variabel Disable Auto Add/Delete Automatisches Hinzufügen/Löschen deaktivieren Disable Opacity Mask Deckkraftmaske deaktivieren Discard Verwerfen + Preserve Beibehalten + TIFF (Black & White) TIFF (Schwarzweiß) + TIFF (8-bit Color) TIFF (8-Bit-Farbe) + High Resolution Hohe Auflösung + Medium Resolution Mittlere Auflösung + Low Resolution Niedrige Auflösung + Language Level 2 Sprachebene 2 + Language Level 3 Sprachebene 3 Discard Empty Type Leeren Text verwerfen Discard Recovered Documents Wiederhergestellte Dokumente verwerfen Discard White Overprint in Output Weißes Überdrucken in der Ausgabe verwerfen @@ -2310,6 +2318,7 @@ file-filter Templates Vorlagen Options for Selection… Optionen für Auswahl… Options: Optionen: Options… Optionen… + Organic Fades Organische Verläufe or more spaces with a tab oder mehr Leerzeichen durch einen Tabulator Order: Reihenfolge: Ordinals Ordinalzahlen @@ -2503,6 +2512,7 @@ diagnostics ui Oberfläche Presets: Vorgaben: Press Mit Preview Vorschau + Preview: Vorschau: Preview All Layers Vorschau aller Ebenen Preview in Browser Vorschau im Browser Preview in Browser needs the desktop app (the web downloads files instead) „Vorschau im Browser“ erfordert die Desktop-App (im Web werden stattdessen Dateien heruntergeladen) @@ -2597,6 +2607,8 @@ digits Malayalam Malayalam Range: Bereich: Raster Effects Rastereffekte Raster File Format: Pixelbild-Dateiformat: + Include Placed Images Platzierte Bilder einschließen + Include Fill Hatches Füllschraffuren einschließen Raster Sample Size: Aufnahmebereich in Pixelbildern: Raster/Vector Balance: Pixelbild-Vektor-Abgleich: Rasterize In Pixelbild umwandeln @@ -2778,6 +2790,10 @@ digits Malayalam Malayalam Replace Font Schrift ersetzen Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersetzen Sie sie durch eine andere Datei oder ignorieren Sie sie: Ihre Bilder zeigen weiterhin die mit dem Dokument gespeicherte Vorschau. Replace Spine Achse ersetzen + Create Spine Achse erstellen + Create Spine: Achse erstellen: + Always Immer + Never Nie Replace Symbol Symbol ersetzen Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen @@ -5658,6 +5674,8 @@ axis Y Axis Y-Achse Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt File SHA-256: {hash} Datei-SHA-256: {hash} Read the objects it is given Die Objekte lesen, die es erhält @@ -5720,6 +5738,12 @@ axis Y Axis Y-Achse @msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt @msg command {name}: {e} Befehl {name}: {e} +@msg manifest: {e} Manifest: {e} +@msg manifest: {e} at line {line} column {column} Manifest: {e} (Zeile {line}, Spalte {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` muss eine Liste von Befehlen sein, jeder mit `id` und `label` +@msg command {name} must be an object with an `id` and a `label` der Befehl {name} muss ein Objekt mit `id` und `label` sein +@msg missing field `{key}` fehlendes Feld `{key}` +@msg duplicate field `{key}` doppeltes Feld `{key}` @msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert @@ -6437,6 +6461,11 @@ breakText Glyphs Glyphen @msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an @msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg +# Copy Troubleshooting Info + Copy Troubleshooting Info Informationen zur Fehlerbehebung kopieren +@msg Copied the troubleshooting info Informationen zur Fehlerbehebung kopiert +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Die letzte VectorCraft-Sitzung reagierte nicht mehr, bevor sie geschlossen wurde. Hilfe › Informationen zur Fehlerbehebung kopieren kopiert die Details für einen Fehlerbericht + # Safe Mode Safe Mode… Abgesicherter Modus… Safe Mode Abgesicherter Modus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8732e8d34..7663ef5045 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1841,6 +1841,7 @@ font-technology Variable Μεταβλητές Options: Επιλογές: Options… Επιλογές… Ordinals Τακτικοί αριθμοί + Organic Fades Οργανικές διαβαθμίσεις Orientation Προσανατολισμός Orientation: Προσανατολισμός: Original Πρωτότυπο @@ -1889,6 +1890,7 @@ font-technology Variable Μεταβλητές Preset: Προεπιλογή: Presets: Προεπιλογές: Preview Προεπισκόπηση + Preview: Προεπισκόπηση: Previous Προηγούμενο Print Εκτύπωση Printer Εκτυπωτής @@ -3285,6 +3287,8 @@ digits Malayalam Μαλαγιάλαμ Randomly change saturation and brightness Τυχαία αλλαγή κορεσμού και φωτεινότητας Raster Effects Εφέ ράστερ Raster File Format: Μορφή αρχείου ράστερ: + Include Placed Images Συμπερίληψη τοποθετημένων εικόνων + Include Fill Hatches Συμπερίληψη διαγραμμίσεων γεμισμάτων Raster Sample Size: Μέγεθος δείγματος ράστερ: Raster/Vector Balance: Ισορροπία ράστερ/διανυσμάτων: Rasterize Unsupported Art Μετατροπή μη υποστηριζόμενου σχεδίου σε ράστερ @@ -3432,6 +3436,10 @@ digits Malayalam Μαλαγιάλαμ Replace Font Αντικατάσταση γραμματοσειράς Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Αντικαταστήστε το με άλλο αρχείο ή αγνοήστε το: οι εικόνες του θα συνεχίσουν να εμφανίζουν την προεπισκόπηση που αποθηκεύτηκε στο έγγραφο. Replace Spine Αντικατάσταση κεντρικής καμπύλης + Create Spine Δημιουργία κεντρικής καμπύλης + Create Spine: Δημιουργία κεντρικής καμπύλης: + Always Πάντα + Never Ποτέ Replace Symbol Αντικατάσταση συμβόλου Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά @@ -6026,6 +6034,8 @@ axis Y Axis Άξονας Y Version: {version} Έκδοση: {version} Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό; This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης File SHA-256: {hash} SHA-256 αρχείου: {hash} Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται @@ -6088,6 +6098,12 @@ axis Y Axis Άξονας Y @msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα @msg command {name}: {e} εντολή {name}: {e} +@msg manifest: {e} αρχείο δηλώσεων: {e} +@msg manifest: {e} at line {line} column {column} αρχείο δηλώσεων: {e} (γραμμή {line}, στήλη {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` το `commands` πρέπει να είναι λίστα εντολών, καθεμία με `id` και `label` +@msg command {name} must be an object with an `id` and a `label` η εντολή {name} πρέπει να είναι αντικείμενο με `id` και `label` +@msg missing field `{key}` λείπει το πεδίο `{key}` +@msg duplicate field `{key}` το πεδίο `{key}` εμφανίζεται δύο φορές @msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα @@ -6802,6 +6818,11 @@ breakText Glyphs Γλύφοι @msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions` @msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων +@msg Copied the troubleshooting info Οι πληροφορίες αντιμετώπισης προβλημάτων αντιγράφηκαν +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Η τελευταία συνεδρία του VectorCraft σταμάτησε να αποκρίνεται πριν κλείσει. Βοήθεια › Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων αντιγράφει τις λεπτομέρειες για αναφορά σφάλματος + # Safe Mode Safe Mode… Ασφαλής λειτουργία… Safe Mode Ασφαλής λειτουργία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7eb51ee4f2..2e7a2baed1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2356,6 +2356,7 @@ font-technology Variable Variables Options for Selection… Opciones de la selección… Options: Opciones: Options… Opciones… + Organic Fades Desvanecidos orgánicos or more spaces with a tab o más espacios por una tabulación Order: Orden: Ordinals Ordinales @@ -2547,6 +2548,7 @@ font-technology Variable Variables Presets: Preajustes: Press Presiona Preview Vista previa + Preview: Vista previa: Preview All Layers Previsualizar todas las capas Preview in Browser Previsualizar en el navegador Preview in Browser needs the desktop app (the web downloads files instead) Previsualizar en el navegador requiere la aplicación de escritorio (la versión web descarga los archivos) @@ -2639,6 +2641,8 @@ digits Malayalam Malayálam Range: Rango: Raster Effects Efectos de rasterización Raster File Format: Formato de archivo ráster: + Include Placed Images Incluir imágenes colocadas + Include Fill Hatches Incluir sombreados de relleno Raster Sample Size: Tamaño de muestra ráster: Raster/Vector Balance: Equilibrio ráster/vectorial: Rasterize Rasterizar @@ -2820,6 +2824,10 @@ digits Malayalam Malayálam Replace Font Reemplazar fuente Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplázalo con otro archivo o ignóralo: sus imágenes seguirán mostrando la vista previa guardada con el documento. Replace Spine Reemplazar eje + Create Spine Crear eje + Create Spine: Crear eje: + Always Siempre + Never Nunca Replace Symbol Reemplazar símbolo Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente @@ -6017,6 +6025,8 @@ axis Y Axis Eje Y Version: {version} Versión: {version} Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte File SHA-256: {hash} SHA-256 del archivo: {hash} Read the objects it is given Leer los objetos que recibe @@ -6079,6 +6089,12 @@ axis Y Axis Eje Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifiesto: {e} +@msg manifest: {e} at line {line} column {column} manifiesto: {e} (línea {line}, columna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` debe ser una lista de comandos, cada uno con `id` y `label` +@msg command {name} must be an object with an `id` and a `label` el comando {name} debe ser un objeto con `id` y `label` +@msg missing field `{key}` falta el campo `{key}` +@msg duplicate field `{key}` el campo `{key}` está repetido @msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada @@ -6793,6 +6809,11 @@ breakText Glyphs Glifos @msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copiar información de solución de problemas +@msg Copied the troubleshooting info Se copió la información de solución de problemas +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La última sesión de VectorCraft dejó de responder antes de cerrarse. Ayuda › Copiar información de solución de problemas copia los detalles para un informe de error + # Safe Mode Safe Mode… Modo seguro… Safe Mode Modo seguro diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433883a44f..2967ba4320 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -269,6 +269,10 @@ Blend Options… Sulautuksen asetukset… Expand Laajenna Replace Spine Korvaa selkäranka + Create Spine Luo selkäranka + Create Spine: Luo selkäranka: + Always Aina + Never Ei koskaan Reverse Spine Käännä selkäranka Reverse Front to Back Käännä edestä taakse Envelope Distort Vääristyskuori @@ -559,6 +563,7 @@ Gaussian Blur… Gaussin sumennus… View Näytä Preview Esikatselu + Preview: Esikatselu: Overprint Preview Päällepainatuksen esikatselu Pixel Preview Pikseliesikatselu Trim View Rajattu näkymä @@ -826,6 +831,7 @@ Spectrum Gradients Spektriliukuvärit Sky Gradients Taivasliukuvärit Neutral Gradients Neutraalit liukuvärit + Organic Fades Orgaaniset häivytykset Duotone Gradients Kaksisävyliukuvärit Radial Glows Säteittäiset hehkut Save Swatch Library… Tallenna värimallikirjasto… @@ -2348,6 +2354,8 @@ digits Malayalam Malajalam Export Selected Art Only Vie vain valittu kuvitus Number of Colors: Värien määrä: Raster File Format: Rasteritiedoston muoto: + Include Placed Images Sisällytä sijoitetut kuvat + Include Fill Hatches Sisällytä täyttöjen viivoitukset True colour needs version 2004 or later: the nearest of 256 colours is written. Täysväri vaatii version 2004 tai uudemman: käytetään lähintä 256 väristä. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teksti muunnetaan ääriviivoiksi, ja viivat, joita CAD-viiva ei pysty piirtämään, muuttuvat täytetyiksi muodoiksi. Type stays text, and every stroke is a line with its lineweight and dashes. Teksti säilyy tekstinä, ja jokainen viiva on viiva, jolla on oma viivanpaksuutensa ja katkoviivansa. @@ -6105,6 +6113,8 @@ axis Y Axis Y-akseli Version: {version} Versio: {version} Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy File SHA-256: {hash} Tiedoston SHA-256: {hash} Read the objects it is given Lukea sille annetut objektit @@ -6167,6 +6177,12 @@ axis Y Axis Y-akseli @msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella @msg command {name}: {e} komento {name}: {e} +@msg manifest: {e} manifesti: {e} +@msg manifest: {e} at line {line} column {column} manifesti: {e} (rivi {line}, sarake {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`-kohdan on oltava luettelo komennoista, joilla kullakin on `id` ja `label` +@msg command {name} must be an object with an `id` and a `label` komennon {name} on oltava objekti, jossa on `id` ja `label` +@msg missing field `{key}` kenttä `{key}` puuttuu +@msg duplicate field `{key}` kenttä `{key}` on kahdesti @msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan @msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu @@ -6885,6 +6901,11 @@ breakText Glyphs Merkit @msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions` @msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopioi vianmääritystiedot +@msg Copied the troubleshooting info Vianmääritystiedot kopioitiin +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraftin edellinen istunto lakkasi vastaamasta ennen kuin se suljettiin. Ohje › Kopioi vianmääritystiedot kopioi tiedot virheraporttia varten + # Safe Mode Safe Mode… Vikasietotila… Safe Mode Vikasietotila diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6995235edc..187b0a89a1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2348,6 +2348,7 @@ font-technology Variable Variables Options for Selection… Options de la sélection… Options: Options : Options… Options… + Organic Fades Fondus organiques or more spaces with a tab espaces ou plus par une tabulation Order: Ordre : Ordinals Ordinaux @@ -2539,6 +2540,7 @@ font-technology Variable Variables Presets: Paramètres prédéfinis : Press Appuyez sur Preview Aperçu + Preview: Aperçu : Preview All Layers Aperçu de tous les calques Preview in Browser Aperçu dans le navigateur Preview in Browser needs the desktop app (the web downloads files instead) L’aperçu dans le navigateur nécessite l’application de bureau (la version web télécharge les fichiers à la place) @@ -2631,6 +2633,8 @@ digits Malayalam Malayalam Range: Plage : Raster Effects Effets de pixellisation Raster File Format: Format du fichier pixellisé : + Include Placed Images Inclure les images importées + Include Fill Hatches Inclure les hachures des fonds Raster Sample Size: Taille de l’échantillon pixellisé : Raster/Vector Balance: Équilibre pixellisé/vectoriel : Rasterize Pixelliser @@ -2812,6 +2816,10 @@ digits Malayalam Malayalam Replace Font Remplacer la police Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Remplacez-le par un autre fichier ou ignorez-le : ses images continueront d’afficher l’aperçu enregistré avec le document. Replace Spine Remplacer la dorsale + Create Spine Créer une dorsale + Create Spine: Créer une dorsale : + Always Toujours + Never Jamais Replace Symbol Remplacer le symbole Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police @@ -6070,6 +6078,8 @@ axis Y Axis Axe Y Version: {version} Version : {version} Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis @@ -6132,6 +6142,12 @@ axis Y Axis Axe Y @msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre @msg command {name}: {e} commande {name} : {e} +@msg manifest: {e} manifeste : {e} +@msg manifest: {e} at line {line} column {column} manifeste : {e} (ligne {line}, colonne {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` doit être une liste de commandes, chacune avec `id` et `label` +@msg command {name} must be an object with an `id` and a `label` la commande {name} doit être un objet avec `id` et `label` +@msg missing field `{key}` champ `{key}` manquant +@msg duplicate field `{key}` champ `{key}` en double @msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` @msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé @@ -6845,6 +6861,11 @@ breakText Glyphs Glyphes @msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` @msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copier les informations de dépannage +@msg Copied the troubleshooting info Informations de dépannage copiées +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La dernière session de VectorCraft a cessé de répondre avant de se fermer. Aide › Copier les informations de dépannage copie les détails pour un rapport de bogue + # Safe Mode Safe Mode… Mode sans échec… Safe Mode Mode sans échec diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 574229b446..4eb6f4f69e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3173,6 +3173,7 @@ antiAlias Sharp शार्प Options for Selection… सिलेक्शन के लिए ऑप्शन्स… Options: ऑप्शन्स: Options… ऑप्शन्स… + Organic Fades ऑर्गेनिक फ़ेड or more spaces with a tab या एक टैब के साथ अधिक रिक्त स्थान Orange नारंगी Order: आदेश: @@ -3501,6 +3502,7 @@ antiAlias Sharp शार्प Press Quality प्रेस गुणवत्ता Pressure दबाव Preview प्रीव्यू + Preview: प्रीव्यू: Preview (1 inch on the page) प्रीव्यू (1 इंच पृष्ठ पर) Preview All Layers सभी लेयर्स का प्रीव्यू करें Preview in Browser ब्राउज़र में प्रीव्यू @@ -3604,6 +3606,8 @@ antiAlias Sharp शार्प Range: रेंज: Raster Effects रीस्टर इफ़ेक्ट Raster File Format: रास्टर फ़ाइल फ़ॉर्मैट: + Include Placed Images रखी गई छवियाँ शामिल करें + Include Fill Hatches फ़िल हैच शामिल करें Raster Sample Size: रास्टर सैंपल साइज़: Raster/Vector Balance: रास्टर/वेक्टर बैलेंस: Rasterize रास्टराइज़ करें @@ -3809,6 +3813,10 @@ antiAlias Sharp शार्प Replace Missing Fonts अनुपलब्ध फ़ॉन्ट रिप्लेस करें Replace Missing Fonts… अनुपलब्ध फ़ॉन्ट रिप्लेस करें… Replace Spine स्पाइन रिप्लेस करें + Create Spine स्पाइन बनाएँ + Create Spine: स्पाइन बनाएँ: + Always हमेशा + Never कभी नहीं Replace Symbol सिंबल रिप्लेस करें Reset Transformation रूपांतरण रीसेट करें Replace With Font इस फ़ॉन्ट से रिप्लेस करें @@ -5375,6 +5383,8 @@ antiAlias Sharp शार्प Version: {version} संस्करण: {version} Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं। + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें। +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें। @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें File SHA-256: {hash} फ़ाइल SHA-256: {hash} Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना @@ -5433,6 +5443,11 @@ antiAlias Sharp शार्प @msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो @msg command {name}: {e} कमांड {name}: {e} +@msg manifest: {e} at line {line} column {column} प्रकटन: {e} (लाइन {line}, कॉलम {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` कमांड की एक सूची होनी चाहिए, हर एक में `id` और `label` हो +@msg command {name} must be an object with an `id` and a `label` कमांड {name} एक ऑब्जेक्ट होना चाहिए जिसमें `id` और `label` हों +@msg missing field `{key}` फ़ील्ड `{key}` नहीं है +@msg duplicate field `{key}` फ़ील्ड `{key}` दो बार है @msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @@ -6392,7 +6407,7 @@ antiAlias Sharp शार्प @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} @msg EPS flattener: {e} ईपीएस flattener: {e} @msg EPS options: {e} ईपीएस ऑप्शन्स: {e} -@msg EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS EPS सेव आर्टबोर्ड और पूरा आर्टवर्क रखता है: क्रॉप किए या केवल सिलेक्ट किए आर्टवर्क के EPS के लिए document.export उपयोग करें +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS सेव पूरा आर्टवर्क लिखता है: केवल सिलेक्ट किए आर्टवर्क के EPS के लिए document.export उपयोग करें @msg every numbered name for {name} is taken in the document's folder {name} के लिए हर नंबर का नाम डॉक्यूमेंट के फ़ोल्डर में लिया जाता है @msg every numbered name for it is taken in the Fonts folder इसके लिए हर नंबर वाला नाम फ़ॉन्ट फ़ोल्डर में लिया जाता है @msg every numbered name for the copy is taken प्रति के लिए हर नंबर का नाम लिया जाता है @@ -8261,6 +8276,11 @@ swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस +# Copy Troubleshooting Info + Copy Troubleshooting Info समस्या निवारण जानकारी कॉपी करें +@msg Copied the troubleshooting info समस्या निवारण जानकारी कॉपी की गई +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft के पिछले सत्र ने बंद होने से पहले प्रतिक्रिया देना बंद कर दिया था। सहायता ›समस्या निवारण जानकारी कॉपी करें बग रिपोर्ट के लिए विवरण कॉपी करता है + # Safe Mode Safe Mode… सुरक्षित मोड… Safe Mode सुरक्षित मोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e4f100338..c60668d353 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3095,6 +3095,7 @@ diagnostics ui felület Press keys… Nyomd le a billentyűket… Pressure Nyomás Preview Előnézet + Preview: Előnézet: Preview All Layers Összes réteg előnézetben Preview in Browser Előnézet böngészőben Preview in Browser needs the desktop app (the web downloads files instead) Az Előnézet böngészőben az asztali alkalmazásban érhető el (a webes változat helyette letölti a fájlokat) @@ -3173,6 +3174,7 @@ mark-style Japanese Japán Radial Blur Körkörös elmosás Radial Blur… Körkörös elmosás… Radial Glows Körkörös ragyogások + Organic Fades Organikus áttűnések Radial Gradient Körkörös színátmenet Conic Gradient Kúpos színátmenet Diamond Gradient Rombusz színátmenet @@ -3191,6 +3193,8 @@ mark-style Japanese Japán Range: Tartomány: Raster Effects Raszteres effektusok Raster File Format: Raszteres fájlformátum: + Include Placed Images Elhelyezett képek hozzáadása + Include Fill Hatches Kitöltések sraffozásainak hozzáadása Raster Sample Size: Raszteres mintaméret: Raster/Vector Balance: Raszter–vektor arány: Rasterize Raszterizálás @@ -3360,6 +3364,10 @@ mark-style Japanese Japán Replace Font Betűtípus cseréje Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Cseréld le egy másik fájlra, vagy hagyd ki: a képei a dokumentummal mentett előnézetet mutatják tovább. Replace Spine Gerinc cseréje + Create Spine Gerinc létrehozása + Create Spine: Gerinc létrehozása: + Always Mindig + Never Soha Replace Symbol Szimbólum cseréje Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus @@ -6058,6 +6066,8 @@ axis Y Axis Y tengely Version: {version} Verzió: {version} Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett File SHA-256: {hash} A fájl SHA-256 értéke: {hash} Read the objects it is given Olvashatja a kapott objektumokat @@ -6120,6 +6130,12 @@ axis Y Axis Y tengely @msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót @msg command {name}: {e} {name} parancs: {e} +@msg manifest: {e} leíró: {e} +@msg manifest: {e} at line {line} column {column} leíró: {e} ({line}. sor, {column}. oszlop) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`: parancsok listája kell, mindegyikben `id` és `label` értékkel +@msg command {name} must be an object with an `id` and a `label` a(z) {name} parancs csak `id` és `label` mezőt tartalmazó objektum lehet +@msg missing field `{key}` hiányzó mező: `{key}` +@msg duplicate field `{key}` ismétlődő mező: `{key}` @msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve @@ -6834,6 +6850,11 @@ breakText Glyphs Glifák @msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket @msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot +# Copy Troubleshooting Info + Copy Troubleshooting Info Hibaelhárítási adatok másolása +@msg Copied the troubleshooting info Hibaelhárítási adatok másolva +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A VectorCraft legutóbbi munkamenete nem válaszolt, mielőtt bezárult. A Súgó › Hibaelhárítási adatok másolása kimásolja a részleteket egy hibajelentéshez + # Safe Mode Safe Mode… Csökkentett mód… Safe Mode Csökkentett mód diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a1df85189e..2be4052fc1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -248,6 +248,10 @@ Blend Options… Opsi blend… Expand Perluas Replace Spine Ganti spine + Create Spine Buat spine + Create Spine: Buat spine: + Always Selalu + Never Tidak pernah Reverse Spine Balikkan spine Reverse Front to Back Balikkan depan ke belakang Envelope Distort Distorsi amplop @@ -520,6 +524,7 @@ Gaussian Blur… Kabur Gaussian… View Tampilan Preview Pratinjau + Preview: Pratinjau: Overprint Preview Pratinjau overprint Pixel Preview Pratinjau piksel Trim View Tampilan terpangkas @@ -772,6 +777,7 @@ Spectrum Gradients Gradien spektrum Sky Gradients Gradien langit Neutral Gradients Gradien netral + Organic Fades Pudar organik Duotone Gradients Gradien duotone Radial Glows Pendar radial Save Swatch Library… Simpan pustaka sampel warna… @@ -2192,6 +2198,8 @@ digits Malayalam Malayalam Export Selected Art Only Ekspor hanya karya seni terpilih Number of Colors: Jumlah warna: Raster File Format: Format file raster: + Include Placed Images Sertakan gambar yang ditempatkan + Include Fill Hatches Sertakan arsiran isian True colour needs version 2004 or later: the nearest of 256 colours is written. Warna sejati memerlukan versi 2004 atau lebih baru: yang ditulis adalah warna terdekat dari 256 warna. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teks menjadi garis luar, dan stroke yang tidak dapat digambar oleh garis CAD menjadi bentuk terisi. Type stays text, and every stroke is a line with its lineweight and dashes. Teks tetap berupa teks, dan setiap stroke menjadi garis dengan ketebalan dan pola putus-putusnya. @@ -5904,6 +5912,7 @@ axis Y Axis Sumbu Y Version: {version} Versi: {version} Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Otomatisasi meminta untuk menginstal plug-in ini. Untuk menginstalnya, pilih Objek › Plug-in › Instal plug-in… sendiri. File SHA-256: {hash} SHA-256 file: {hash} Read the objects it is given Membaca objek yang diberikan kepadanya Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen @@ -6574,6 +6583,11 @@ breakText Glyphs Glif No importer plug-ins installed Tidak ada plug-in impor yang terinstal Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya +# Copy Troubleshooting Info + Copy Troubleshooting Info Salin info pemecahan masalah +@msg Copied the troubleshooting info Berhasil menyalin info pemecahan masalah +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Sesi terakhir VectorCraft berhenti merespons sebelum ditutup. Bantuan › Salin info pemecahan masalah menyalin detailnya untuk laporan bug + # Safe Mode Safe Mode… Mode aman… Safe Mode Mode aman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c1400c91e3..9711fbd158 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -829,6 +829,14 @@ font-technology Variable Variabili Disable Auto Add/Delete Disattiva aggiunta/eliminazione automatica Disable Opacity Mask Disattiva maschera di opacità Discard Scarta + Preserve Mantieni + TIFF (Black & White) TIFF (bianco e nero) + TIFF (8-bit Color) TIFF (colore a 8 bit) + High Resolution Alta risoluzione + Medium Resolution Media risoluzione + Low Resolution Bassa risoluzione + Language Level 2 Livello di linguaggio 2 + Language Level 3 Livello di linguaggio 3 Discard Empty Type Scarta testo vuoto Discard Recovered Documents Scarta documenti recuperati Discard White Overprint in Output Scarta sovrastampa del bianco nell'output @@ -2386,6 +2394,7 @@ file-filter Templates Modelli Options for Selection… Opzioni per la selezione… Options: Opzioni: Options… Opzioni… + Organic Fades Dissolvenze organiche or more spaces with a tab o più spazi con una tabulazione Order: Ordine: Ordinals Ordinali @@ -2579,6 +2588,7 @@ diagnostics ui interfaccia Presets: Preset: Press Premi Preview Anteprima + Preview: Anteprima: Preview All Layers Anteprima di tutti i livelli Preview in Browser Anteprima nel browser Preview in Browser needs the desktop app (the web downloads files instead) Anteprima nel browser richiede l'app desktop (la versione web scarica invece i file) @@ -2673,6 +2683,8 @@ digits Malayalam Malayalam Range: Intervallo: Raster Effects Effetti raster Raster File Format: Formato file raster: + Include Placed Images Includi immagini inserite + Include Fill Hatches Includi tratteggi dei riempimenti Raster Sample Size: Dimensione campione raster: Raster/Vector Balance: Bilanciamento raster/vettoriale: Rasterize Rasterizza @@ -2854,6 +2866,10 @@ digits Malayalam Malayalam Replace Font Sostituisci font Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Sostituiscilo con un altro file oppure ignoralo: le sue immagini continueranno a mostrare l'anteprima salvata con il documento. Replace Spine Sostituisci dorsale + Create Spine Crea dorsale + Create Spine: Crea dorsale: + Always Sempre + Never Mai Replace Symbol Sostituisci simbolo Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font @@ -5692,6 +5708,8 @@ axis Y Axis Asse Y Version: {version} Versione: {version} Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente File SHA-256: {hash} SHA-256 del file: {hash} Read the objects it is given Leggere gli oggetti che riceve @@ -5754,6 +5772,12 @@ axis Y Axis Asse Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (riga {line}, colonna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` deve essere un elenco di comandi, ciascuno con `id` e `label` +@msg command {name} must be an object with an `id` and a `label` il comando {name} deve essere un oggetto con `id` e `label` +@msg missing field `{key}` campo `{key}` mancante +@msg duplicate field `{key}` campo `{key}` duplicato @msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla @@ -6471,6 +6495,11 @@ breakText Glyphs Glifi @msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copia informazioni per la risoluzione dei problemi +@msg Copied the troubleshooting info Informazioni per la risoluzione dei problemi copiate +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report L’ultima sessione di VectorCraft ha smesso di rispondere prima di chiudersi. Aiuto › Copia informazioni per la risoluzione dei problemi copia i dettagli per una segnalazione di bug + # Safe Mode Safe Mode… Modalità provvisoria… Safe Mode Modalità provvisoria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8a9e0cedc9..571df35191 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -290,6 +290,10 @@ digits Malayalam マラヤーラム数字 Blend Options… ブレンドオプション… Expand 拡張 Replace Spine スパインを置き換え + Create Spine スパインを作成 + Create Spine: スパインを作成: + Always 常に + Never しない Reverse Spine スパインを反転 Reverse Front to Back 前後を反転 Envelope Distort エンベロープ @@ -859,6 +863,7 @@ digits Malayalam マラヤーラム数字 Spectrum Gradients スペクトルグラデーション Sky Gradients 空のグラデーション Neutral Gradients ニュートラルグラデーション + Organic Fades オーガニックフェード Duotone Gradients ダブルトーングラデーション Radial Glows 円形グロー Save Swatch Library… スウォッチライブラリを保存… @@ -870,6 +875,7 @@ digits Malayalam マラヤーラム数字 Search Commands… コマンドを検索… VectorCraft Help… VectorCraft ヘルプ… Preview プレビュー + Preview: プレビュー: Show Edges エッジを表示 Show Corner Widget コーナーウィジェットを表示 Show Template テンプレートを表示 @@ -1459,6 +1465,14 @@ antiAlias Sharp シャープ Disable Auto Add/Delete 自動追加/削除機能を使用しない Disable Opacity Mask 不透明マスクを無効にする Discard 破棄 + Preserve 保持 + TIFF (Black & White) TIFF (白黒) + TIFF (8-bit Color) TIFF (8 bit カラー) + High Resolution 高解像度 + Medium Resolution 中解像度 + Low Resolution 低解像度 + Language Level 2 言語レベル 2 + Language Level 3 言語レベル 3 Discard Empty Type 空のテキストを破棄 Discard Recovered Documents 復帰したドキュメントを破棄 Discard White Overprint in Output 出力時に白のオーバープリントを破棄 @@ -2717,6 +2731,8 @@ mark-style Japanese 日本式 Randomly change saturation and brightness 彩度と明度をランダムに変更 Range: 範囲: Raster File Format: ラスターファイル形式: + Include Placed Images 配置画像を含める + Include Fill Hatches 塗りのハッチングを含める Raster Sample Size: ラスターのサンプルサイズ: Raster/Vector Balance: ラスター/ベクターのバランス: Rasterize Unsupported Art 非対応のアートをラスタライズ @@ -5757,6 +5773,8 @@ axis Y Axis Y 軸 Version: {version} バージョン: {version} Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください File SHA-256: {hash} ファイルの SHA-256: {hash} Read the objects it is given 渡されたオブジェクトの読み取り @@ -5819,6 +5837,12 @@ axis Y Axis Y 軸 @msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください @msg command {name}: {e} コマンド {name}: {e} +@msg manifest: {e} マニフェスト: {e} +@msg manifest: {e} at line {line} column {column} マニフェスト: {e} ({line} 行目、{column} 列目) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` は、それぞれ `id` と `label` を持つコマンドのリストにしてください +@msg command {name} must be an object with an `id` and a `label` コマンド {name} は `id` と `label` を持つオブジェクトにしてください +@msg missing field `{key}` フィールド `{key}` がありません +@msg duplicate field `{key}` フィールド `{key}` が重複しています @msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです @msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした @@ -6536,6 +6560,11 @@ breakText Glyphs 字形 @msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです @msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください +# Copy Troubleshooting Info + Copy Troubleshooting Info トラブルシューティング情報をコピー +@msg Copied the troubleshooting info トラブルシューティング情報をコピーしました +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 前回の VectorCraft のセッションは、閉じる前に応答しなくなりました。ヘルプ › トラブルシューティング情報をコピー で、バグ報告用の詳細をコピーできます + # Safe Mode Safe Mode… セーフモード… Safe Mode セーフモード diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83196486e7..98281d92d9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -220,6 +220,10 @@ Blend Options… გადასვლის პარამეტრები… Expand გაშლა Replace Spine ხერხემლის ჩანაცვლება + Create Spine ხერხემლის შექმნა + Create Spine: ხერხემლის შექმნა: + Always ყოველთვის + Never არასოდეს Reverse Spine ხერხემლის შებრუნება Reverse Front to Back რიგის შებრუნება წინიდან უკან Envelope Distort გარსით დეფორმაცია @@ -495,6 +499,7 @@ Gaussian Blur… გაუსის გაბუნდოვნება… View ხედი Preview წინასწარი ნახვა + Preview: წინასწარი ნახვა: Overprint Preview ზედდაბეჭდვის წინასწარი ნახვა Pixel Preview პიქსელების წინასწარი ნახვა Trim View მოჭრილი ხედი @@ -732,6 +737,7 @@ Spectrum Gradients სპექტრის გრადიენტები Sky Gradients ცის გრადიენტები Neutral Gradients ნეიტრალური გრადიენტები + Organic Fades ორგანული გაქრობები Duotone Gradients ორტონიანი გრადიენტები Radial Glows რადიალური ნათებები Save Swatch Library… ფერის ნიმუშების ბიბლიოთეკის შენახვა… @@ -2115,6 +2121,8 @@ digits Malayalam მალაიალამური Export Selected Art Only მხოლოდ მონიშნული ნახატის ექსპორტირება Number of Colors: ფერების რაოდენობა: Raster File Format: რასტრული ფაილის ფორმატი: + Include Placed Images განთავსებული გამოსახულებების ჩართვა + Include Fill Hatches შევსებების დაშტრიხვის ჩართვა True colour needs version 2004 or later: the nearest of 256 colours is written. სრულ ფერს სჭირდება 2004 ან უფრო ახალი ვერსია: იწერება 256 ფერიდან უახლოესი. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. ტექსტი კონტურებად იქცევა, ხოლო ის კონტურები, რომლებსაც CAD-ხაზი ვერ ხატავს, შევსებულ ფიგურებად. Type stays text, and every stroke is a line with its lineweight and dashes. ტექსტი ტექსტად რჩება, ყოველი კონტური კი ხაზია თავისი სისქითა და ტირეებით. @@ -6026,6 +6034,8 @@ axis Y Axis Y ღერძი Version: {version} ვერსია: {version} Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით File SHA-256: {hash} ფაილის SHA-256: {hash} Read the objects it is given მიღებული ობიექტების წაკითხვა @@ -6088,6 +6098,12 @@ axis Y Axis Y ღერძი @msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება @msg command {name}: {e} ბრძანება {name}: {e} +@msg manifest: {e} მანიფესტი: {e} +@msg manifest: {e} at line {line} column {column} მანიფესტი: {e} (სტრიქონი {line}, სვეტი {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` უნდა იყოს ბრძანებების სია, თითოეული `id`-ით და `label`-ით +@msg command {name} must be an object with an `id` and a `label` ბრძანება {name} უნდა იყოს ობიექტი `id`-ით და `label`-ით +@msg missing field `{key}` აკლია ველი `{key}` +@msg duplicate field `{key}` ველი `{key}` ორჯერაა @msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა @@ -6959,6 +6975,11 @@ font-script Japanese იაპონური @msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს @msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info პრობლემების მოგვარების ინფორმაციის კოპირება +@msg Copied the troubleshooting info პრობლემების მოგვარების ინფორმაცია კოპირებულია +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft-ის ბოლო სესიამ დახურვამდე პასუხის გაცემა შეწყვიტა. დახმარება › პრობლემების მოგვარების ინფორმაციის კოპირება აკოპირებს დეტალებს შეცდომის ანგარიშისთვის + # Safe Mode Safe Mode… უსაფრთხო რეჟიმი… Safe Mode უსაფრთხო რეჟიმი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 060c421f28..cad90f44ed 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -283,6 +283,10 @@ digits Malayalam 말라얄람어 Blend Options… 블렌드 옵션… Expand 확장 Replace Spine 스파인 바꾸기 + Create Spine 스파인 만들기 + Create Spine: 스파인 만들기: + Always 항상 + Never 안 함 Reverse Spine 스파인 반전 Reverse Front to Back 앞뒤 반전 Envelope Distort 엔벨로프 왜곡 @@ -852,6 +856,7 @@ digits Malayalam 말라얄람어 Spectrum Gradients 스펙트럼 그레이디언트 Sky Gradients 하늘 그레이디언트 Neutral Gradients 중간색 그레이디언트 + Organic Fades 유기적 페이드 Duotone Gradients 듀오톤 그레이디언트 Radial Glows 방사형 광선 Save Swatch Library… 견본 라이브러리 저장… @@ -863,6 +868,7 @@ digits Malayalam 말라얄람어 Search Commands… 명령 검색… VectorCraft Help… VectorCraft 도움말… Preview 미리 보기 + Preview: 미리 보기: Show Edges 가장자리 표시 Show Corner Widget 모퉁이 위젯 표시 Show Template 템플릿 표시 @@ -1449,6 +1455,14 @@ antiAlias Sharp 날카롭게 Disable Auto Add/Delete 자동 추가/삭제 사용 안 함 Disable Opacity Mask 불투명 마스크 사용 안 함 Discard 삭제 + Preserve 보존 + TIFF (Black & White) TIFF (흑백) + TIFF (8-bit Color) TIFF (8비트 색상) + High Resolution 고해상도 + Medium Resolution 중간 해상도 + Low Resolution 저해상도 + Language Level 2 언어 수준 2 + Language Level 3 언어 수준 3 Discard Empty Type 빈 문자 삭제 Discard Recovered Documents 복구된 문서 삭제 Discard White Overprint in Output 출력에서 흰색 오버프린트 삭제 @@ -2707,6 +2721,8 @@ mark-style Japanese 일본식 Randomly change saturation and brightness 채도와 명도를 무작위로 변경 Range: 범위: Raster File Format: 래스터 파일 형식: + Include Placed Images 배치된 이미지 포함 + Include Fill Hatches 칠 해치 포함 Raster Sample Size: 래스터 샘플 크기: Raster/Vector Balance: 래스터/벡터 균형: Rasterize Unsupported Art 지원되지 않는 아트웍 래스터화 @@ -5653,6 +5669,8 @@ axis Y Axis Y축 Version: {version} 버전: {version} Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요 File SHA-256: {hash} 파일 SHA-256: {hash} Read the objects it is given 전달받은 개체 읽기 @@ -5715,6 +5733,12 @@ axis Y Axis Y축 @msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 @msg command {name}: {e} 명령 {name}: {e} +@msg manifest: {e} 매니페스트: {e} +@msg manifest: {e} at line {line} column {column} 매니페스트: {e} ({line}행 {column}열) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`는 각각 `id`와 `label`이 있는 명령 목록이어야 합니다 +@msg command {name} must be an object with an `id` and a `label` 명령 {name}은(는) `id`와 `label`이 있는 객체여야 합니다 +@msg missing field `{key}` `{key}` 필드가 없습니다 +@msg duplicate field `{key}` `{key}` 필드가 중복되었습니다 @msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 @@ -6432,6 +6456,11 @@ breakText Glyphs 글리프 @msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다 @msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요 +# Copy Troubleshooting Info + Copy Troubleshooting Info 문제 해결 정보 복사 +@msg Copied the troubleshooting info 문제 해결 정보를 복사했습니다 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 지난 VectorCraft 세션이 닫히기 전에 응답을 멈췄습니다. 도움말 › 문제 해결 정보 복사로 버그 보고용 세부 정보를 복사할 수 있습니다 + # Safe Mode Safe Mode… 안전 모드… Safe Mode 안전 모드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e494230f8c..9d71bc2932 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -262,6 +262,10 @@ Blend Options… Vermengopties… Expand Uitbreiden Replace Spine Rug vervangen + Create Spine Rug maken + Create Spine: Rug maken: + Always Altijd + Never Nooit Reverse Spine Rug omkeren Reverse Front to Back Van voor naar achter omkeren Envelope Distort Envelopvervorming @@ -534,6 +538,7 @@ Gaussian Blur… Gaussiaanse vervaging… View Beeld Preview Voorbeeld + Preview: Voorbeeld: Overprint Preview Overprintvoorbeeld Pixel Preview Pixelvoorbeeld Trim View Bijsnijweergave @@ -786,6 +791,7 @@ Spectrum Gradients Spectrumverlopen Sky Gradients Luchtverlopen Neutral Gradients Neutrale verlopen + Organic Fades Organische vervagingen Duotone Gradients Duotoonverlopen Radial Glows Radiale gloed Save Swatch Library… Staalenbibliotheek opslaan… @@ -2206,6 +2212,8 @@ digits Malayalam Malayalam Export Selected Art Only Alleen geselecteerde artwork exporteren Number of Colors: Aantal kleuren: Raster File Format: Rasterbestandsindeling: + Include Placed Images Geplaatste afbeeldingen opnemen + Include Fill Hatches Arceringen van vullingen opnemen True colour needs version 2004 or later: the nearest of 256 colours is written. Echte kleur vereist versie 2004 of later: de dichtstbijzijnde van 256 kleuren wordt weggeschreven. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst wordt omlijningen, en lijnen die een CAD-lijn niet kan tekenen, worden gevulde vormen. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst blijft tekst en elke lijn wordt een lijn met zijn eigen lijndikte en streepjes. @@ -5955,6 +5963,7 @@ axis Y Axis Y-as Version: {version} Versie: {version} Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Een automatisering heeft gevraagd deze plug-in te installeren. Kies zelf Object › Plug-ins › Plug-in installeren… om hem te installeren. File SHA-256: {hash} SHA-256 van het bestand: {hash} Read the objects it is given De objecten lezen die het krijgt Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen @@ -6625,6 +6634,11 @@ breakText Glyphs Glyphs No importer plug-ins installed Geen importplug-ins geïnstalleerd Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken +# Copy Troubleshooting Info + Copy Troubleshooting Info Probleemoplossingsgegevens kopiëren +@msg Copied the troubleshooting info Probleemoplossingsgegevens gekopieerd +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report De laatste sessie van VectorCraft reageerde niet meer voordat deze werd gesloten. Help › Probleemoplossingsgegevens kopiëren kopieert de details voor een foutrapport + # Safe Mode Safe Mode… Veilige modus… Safe Mode Veilige modus diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 052d39351a..1e4ff15acb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -226,6 +226,10 @@ Blend Options… Opcje przejścia… Expand Rozszerz Replace Spine Zastąp grzbiet + Create Spine Utwórz grzbiet + Create Spine: Utwórz grzbiet: + Always Zawsze + Never Nigdy Reverse Spine Odwróć grzbiet Reverse Front to Back Odwróć kolejność od przodu do tyłu Envelope Distort Zniekształcenie kopertowe @@ -524,6 +528,7 @@ Gaussian Blur… Rozmycie gaussowskie… View Widok Preview Podgląd + Preview: Podgląd: Overprint Preview Podgląd nadruku Pixel Preview Podgląd pikseli Trim View Widok przycięty @@ -782,6 +787,7 @@ Spectrum Gradients Gradienty widma Sky Gradients Gradienty nieba Neutral Gradients Gradienty neutralne + Organic Fades Organiczne zanikania Duotone Gradients Gradienty dwutonowe Radial Glows Blaski promieniste Save Swatch Library… Zapisz bibliotekę próbek… @@ -2304,6 +2310,8 @@ digits Malayalam Malajalam Export Selected Art Only Eksportuj tylko zaznaczoną kompozycję Number of Colors: Liczba kolorów: Raster File Format: Format pliku rastrowego: + Include Placed Images Uwzględnij umieszczone obrazy + Include Fill Hatches Uwzględnij kreskowania wypełnień True colour needs version 2004 or later: the nearest of 256 colours is written. Pełny kolor wymaga wersji 2004 lub nowszej: zapisywany jest najbliższy z 256 kolorów. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst staje się konturami, a obrysy, których linia CAD nie narysuje, stają się wypełnionymi kształtami. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst pozostaje tekstem, a każdy obrys jest linią z grubością i kreskami. @@ -5929,6 +5937,8 @@ axis Y Axis Oś Y Version: {version} Wersja: {version} Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi File SHA-256: {hash} SHA-256 pliku: {hash} Read the objects it is given Odczytywać obiekty, które otrzymuje @@ -5991,6 +6001,12 @@ axis Y Axis Oś Y @msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery @msg command {name}: {e} polecenie {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (wiersz {line}, kolumna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` musi być listą poleceń, każde z `id` i `label` +@msg command {name} must be an object with an `id` and a `label` polecenie {name} musi być obiektem z `id` i `label` +@msg missing field `{key}` brak pola `{key}` +@msg duplicate field `{key}` zduplikowane pole `{key}` @msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano @@ -6706,6 +6722,11 @@ breakText Glyphs Glify @msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu @msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiuj informacje diagnostyczne +@msg Copied the troubleshooting info Skopiowano informacje diagnostyczne +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Ostatnia sesja VectorCraft przestała odpowiadać przed zamknięciem. Pomoc › Kopiuj informacje diagnostyczne kopiuje szczegóły do zgłoszenia błędu + # Safe Mode Safe Mode… Tryb awaryjny… Safe Mode Tryb awaryjny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f78a08845..028582361f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1953,6 +1953,7 @@ antiAlias Sharp Nítido Options… Opções… Order: Ordem: Ordinals Ordinais + Organic Fades Esmaecimentos Orgânicos Orientation Orientação Orientation: Orientação: Original Original @@ -2120,6 +2121,7 @@ antiAlias Sharp Nítido Presets: Predefinidos: Press Pressione Preview Visualização + Preview: Visualização: Preview Mode Modo de Visualização Preview in Browser Visualizar no Navegador Preview the document as it is now Visualizar o documento como está agora @@ -2201,6 +2203,8 @@ digits Malayalam Malaiala Range: Intervalo: Raster Effects Efeitos de Raster Raster File Format: Formato de Arquivo Raster: + Include Placed Images Incluir Imagens Inseridas + Include Fill Hatches Incluir Hachuras dos Preenchimentos Raster Sample Size: Tamanho da Amostra Raster: Raster/Vector Balance: Equilíbrio Raster/Vetor: Rasterize Unsupported Art Rasterizar Arte Não Suportada @@ -2356,6 +2360,10 @@ digits Malayalam Malaiala Replace: Substituir: Replace Font Substituir Fonte Replace Spine Substituir Coluna + Create Spine Criar Coluna + Create Spine: Criar Coluna: + Always Sempre + Never Nunca Replace Symbol Substituir Símbolo Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte @@ -5990,6 +5998,8 @@ axis Y Axis Eixo Y Version: {version} Versão: {version} Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar File SHA-256: {hash} SHA-256 do arquivo: {hash} Read the objects it is given Ler os objetos que recebe @@ -6052,6 +6062,12 @@ axis Y Axis Eixo Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifesto: {e} +@msg manifest: {e} at line {line} column {column} manifesto: {e} (linha {line}, coluna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` deve ser uma lista de comandos, cada um com `id` e `label` +@msg command {name} must be an object with an `id` and a `label` o comando {name} deve ser um objeto com `id` e `label` +@msg missing field `{key}` falta o campo `{key}` +@msg duplicate field `{key}` campo `{key}` duplicado @msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado @@ -6923,6 +6939,11 @@ swatch Yellow Green Verde-amarelado @msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions` @msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Copiar informações de solução de problemas +@msg Copied the troubleshooting info Informações de solução de problemas copiadas +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A última sessão do VectorCraft parou de responder antes de fechar. Ajuda › Copiar informações de solução de problemas copia os detalhes para um relatório de bug + # Safe Mode Safe Mode… Modo de Segurança… Safe Mode Modo de Segurança diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e50ebf68a0..342d9884e7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2381,6 +2381,7 @@ file-filter Templates Шаблоны Options for Selection… Параметры выделения… Options: Параметры: Options… Параметры… + Organic Fades Органические затухания or more spaces with a tab или больше пробелов на табуляцию Order: Порядок: Ordinals Порядковые числительные @@ -2574,6 +2575,7 @@ diagnostics ui интерфейс Presets: Наборы: Press Нажмите Preview Просмотр + Preview: Просмотр: Preview All Layers Просмотреть все слои Preview in Browser Просмотреть в браузере Preview in Browser needs the desktop app (the web downloads files instead) Просмотр в браузере требует настольного приложения (web-версия скачивает файлы) @@ -2668,6 +2670,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растровые эффекты Raster File Format: Формат растрового файла: + Include Placed Images Включать размещенные изображения + Include Fill Hatches Включать штриховку заливок Raster Sample Size: Размер растрового образца: Raster/Vector Balance: Баланс растровых/векторных: Rasterize Растеризовать @@ -2849,6 +2853,10 @@ digits Malayalam Малаялам Replace Font Заменить шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замените его другим файлом или игнорируйте: его изображения продолжат показывать предварительный просмотр, сохранённый с документом. Replace Spine Заменить остов + Create Spine Создать остов + Create Spine: Создать остов: + Always Всегда + Never Никогда Replace Symbol Заменить символ Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом @@ -5686,6 +5694,8 @@ axis Y Axis Ось Y Version: {version} Версия: {version} Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится File SHA-256: {hash} SHA-256 файла: {hash} Read the objects it is given Читать переданные ему объекты @@ -5748,6 +5758,12 @@ axis Y Axis Ось Y @msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы @msg command {name}: {e} команда {name}: {e} +@msg manifest: {e} манифест: {e} +@msg manifest: {e} at line {line} column {column} манифест: {e} (строка {line}, столбец {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` должно быть списком команд, у каждой из которых есть `id` и `label` +@msg command {name} must be an object with an `id` and a `label` команда {name} должна быть объектом с `id` и `label` +@msg missing field `{key}` нет поля `{key}` +@msg duplicate field `{key}` поле `{key}` указано дважды @msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено @@ -6939,6 +6955,11 @@ shape Open Открытый @msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта @msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Копировать сведения для устранения неполадок +@msg Copied the troubleshooting info Сведения для устранения неполадок скопированы +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последний сеанс VectorCraft перестал отвечать перед закрытием. Справка › Копировать сведения для устранения неполадок копирует подробности для отчёта об ошибке + # Safe Mode Safe Mode… Безопасный режим… Safe Mode Безопасный режим diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 79725b4435..cca9d052cf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2295,6 +2295,7 @@ font-technology Variable Variabla Options for Selection… Alternativ för markering… Options: Alternativ: Options… Alternativ… + Organic Fades Organiska toningar or more spaces with a tab eller fler blanksteg med en tabb Order: Ordning: Ordinals Ordningstal @@ -2484,6 +2485,7 @@ font-technology Variable Variabla Presets: Förinställningar: Press Tryck på Preview Förhandsgranskning + Preview: Förhandsgranskning: Preview All Layers Förhandsgranska alla lager Preview in Browser Förhandsgranska i webbläsare Preview in Browser needs the desktop app (the web downloads files instead) Förhandsgranska i webbläsare kräver skrivbordsappen (webbversionen hämtar filerna i stället) @@ -2576,6 +2578,8 @@ digits Malayalam Malayalam Range: Omfång: Raster Effects Rastereffekter Raster File Format: Rasterfilformat: + Include Placed Images Inkludera placerade bilder + Include Fill Hatches Inkludera skrafferingar av fyllningar Raster Sample Size: Samplingsstorlek för raster: Raster/Vector Balance: Raster-/vektorbalans: Rasterize Rastrera @@ -2756,6 +2760,10 @@ digits Malayalam Malayalam Replace Font Ersätt typsnitt Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersätt den med en annan fil eller ignorera den: dess bilder fortsätter att visa förhandsgranskningen som sparats med dokumentet. Replace Spine Ersätt ryggrad + Create Spine Skapa ryggrad + Create Spine: Skapa ryggrad: + Always Alltid + Never Aldrig Replace Symbol Ersätt symbol Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt @@ -6097,6 +6105,8 @@ axis Y Axis Y-axel Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Läsa objekten den får @@ -6155,6 +6165,11 @@ axis Y Axis Y-axel @msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav @msg command {name}: {e} kommando {name}: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (rad {line}, kolumn {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` måste vara en lista med kommandon, vart och ett med `id` och `label` +@msg command {name} must be an object with an `id` and a `label` kommandot {name} måste vara ett objekt med `id` och `label` +@msg missing field `{key}` fältet `{key}` saknas +@msg duplicate field `{key}` fältet `{key}` förekommer två gånger @msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` @msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades @@ -7462,7 +7477,7 @@ digits Hindi Hindi @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} @msg EPS flattener: {e} EPS-utplattning: {e} @msg EPS options: {e} EPS-alternativ: {e} -@msg EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS När du sparar som EPS behålls ritytan och allt bildmaterial: använd document.export för beskuren EPS eller EPS med bara det markerade +@msg EPS Save writes all artwork: use document.export for selected-only EPS När du sparar som EPS skrivs allt bildmaterial: använd document.export för EPS med bara det markerade @msg every numbered name for the result is taken alla numrerade namn för resultatet är upptagna @msg expanded SVG styles exceed the import size limit de expanderade SVG-stilarna överskrider storleksgränsen för import @msg expected {"dataBase64"} förväntade {"dataBase64"} @@ -8330,6 +8345,11 @@ digits Hindi Hindi @msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions` @msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Kopiera felsökningsinformation +@msg Copied the troubleshooting info Kopierade felsökningsinformationen +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den senaste VectorCraft-sessionen slutade svara innan den stängdes. Hjälp › Kopiera felsökningsinformation kopierar detaljerna till en felrapport + # Safe Mode Safe Mode… Felsäkert läge… Safe Mode Felsäkert läge diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0612d7ca7d..be5c132dbd 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -238,6 +238,27 @@ fn anti_aliasing_method_labels_are_in_complete_catalogs() { } } +/// The names the EPS options' menus show (preview, overprints, flattener preset and PostScript +/// level), and Save As's Preview label. +#[test] +fn eps_option_menu_names_are_in_complete_catalogs() { + use vectorcraft_engine::cmd::FlattenOptions; + use vectorcraft_eps::{Level, Overprint, Preview}; + let names: Vec<&str> = Preview::ALL + .map(Preview::label) + .into_iter() + .chain(Overprint::ALL.map(Overprint::label)) + .chain(Level::ALL.map(Level::label)) + .chain(FlattenOptions::PRESETS.into_iter().filter_map(FlattenOptions::preset_label)) + .chain(["Preview:"]) + .collect(); + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in &names { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] @@ -2006,6 +2027,38 @@ fn plugin_limit_errors_are_translated() { } } +/// A manifest whose `commands` value has the wrong shape is refused with an error that every +/// language of [`COMPLETE_MESSAGES`] translates whole, the command's name and the place in the +/// manifest included. +#[test] +fn wrongly_shaped_plugin_commands_errors_are_translated() { + let many: Vec = (0..=32).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect(); + let commands = [ + r#"{"id":"x"}"#.to_string(), + r#""x""#.to_string(), + "[1]".to_string(), + r#"[{"id":1,"label":"A"}]"#.to_string(), + r#"[{"id":"x","label":true}]"#.to_string(), + r#"[{"label":"B"}]"#.to_string(), + r#"[{"id":"x"}]"#.to_string(), + r#"[{"id":"x","id":"y","label":"A"}]"#.to_string(), + format!("[{}]", many.join(",")), + ]; + let english = ["must", "list", "missing", "field", "duplicate", "object", "line", "column", "each", "with", "invalid", "expected"]; + for value in &commands { + let manifest = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":{value}}}"#); + let e = vectorcraft_plugins::manifest::Manifest::parse(manifest.as_bytes()).unwrap_err().to_string(); + let numbers: Vec<&str> = e.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect(); + for code in COMPLETE_MESSAGES { + let shown = message(Lang::from_code(code).expect("registered"), &e); + // Backticked names stay in English; the words around them are translated. + let words: String = shown.split('`').step_by(2).collect(); + assert!(words.split(|c: char| !c.is_alphabetic()).all(|w| !english.contains(&w)), "{code}: {e} -> {shown}"); + assert!(numbers.iter().all(|n| shown.contains(n)), "{code}: {e} -> {shown}"); + } + } +} + /// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above /// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 05020e1676..3a2177b404 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2167,6 +2167,7 @@ shape Open Açık Options for Selection… Seçim Seçenekleri… Options: Seçenekler: Options… Seçenekler… + Organic Fades Organik Solmalar or more spaces with a tab veya sekmeli daha fazla alan Order: Sipariş: Ordinals Sıra Sayıları @@ -2357,6 +2358,7 @@ shape Open Açık Presets: Ön ayarlar: Press Basın Preview Önizleme + Preview: Önizleme: Preview All Layers Tüm Katmanları Önizle Preview in Browser Tarayıcıda Önizleme Preview in Browser needs the desktop app (the web downloads files instead) Tarayıcıdaki önizleme için masaüstü uygulaması gerekir (bunun yerine web, dosyaları indirir) @@ -2448,6 +2450,8 @@ digits Malayalam Malayalam Range: Aralık: Raster Effects Raster Efektleri Raster File Format: Raster Dosya Formatı: + Include Placed Images Yerleştirilen Görüntüleri Dahil Et + Include Fill Hatches Dolgu Taramalarını Dahil Et Raster Sample Size: Raster Örnek Boyutu: Raster/Vector Balance: Raster/Vektör Dengesi: Rasterize Rasterleştir @@ -2625,6 +2629,10 @@ digits Malayalam Malayalam Replace Font Yazı Tipini Değiştir Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Onu başka bir dosyayla değiştirin veya yok sayın: resimleri, belgeyle birlikte kaydedilen önizlemeyi göstermeye devam eder. Replace Spine Omurgayı Değiştir + Create Spine Omurga Oluştur + Create Spine: Omurga Oluştur: + Always Her Zaman + Never Asla Replace Symbol Sembolü Değiştir Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir @@ -6031,6 +6039,8 @@ axis Y Axis Y ekseni Version: {version} Sürüm: {version} Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin File SHA-256: {hash} Dosya SHA-256: {hash} Read the objects it is given Kendisine verilen nesneleri okuma @@ -6093,6 +6103,12 @@ axis Y Axis Y ekseni @msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın @msg command {name}: {e} {name} komutu: {e} +@msg manifest: {e} bildirim: {e} +@msg manifest: {e} at line {line} column {column} bildirim: {e} (satır {line}, sütun {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`, her biri `id` ve `label` içeren bir komut listesi olmalıdır +@msg command {name} must be an object with an `id` and a `label` {name} komutu `id` ve `label` içeren bir nesne olmalıdır +@msg missing field `{key}` `{key}` alanı eksik +@msg duplicate field `{key}` `{key}` alanı yinelenmiş @msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi @@ -6806,6 +6822,11 @@ breakText Glyphs Glifler @msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir @msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın +# Copy Troubleshooting Info + Copy Troubleshooting Info Sorun Giderme Bilgilerini Kopyala +@msg Copied the troubleshooting info Sorun giderme bilgileri kopyalandı +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft'ın son oturumu kapanmadan önce yanıt vermeyi durdurdu. Yardım › Sorun Giderme Bilgilerini Kopyala, hata raporu için ayrıntıları kopyalar + # Safe Mode Safe Mode… Güvenli Mod… Safe Mode Güvenli Mod diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13e60355b5..67df483102 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -823,6 +823,14 @@ font-technology Variable Варіативні Disable Auto Add/Delete Вимкнути авто-додавання/видалення Disable Opacity Mask Вимкнути маску непрозорості Discard Відкинути + Preserve Зберігати + TIFF (Black & White) TIFF (чорно-білий) + TIFF (8-bit Color) TIFF (8-бітний колір) + High Resolution Висока роздільна здатність + Medium Resolution Середня роздільна здатність + Low Resolution Низька роздільна здатність + Language Level 2 Рівень мови 2 + Language Level 3 Рівень мови 3 Discard Empty Type Видалити порожній текст Discard Recovered Documents Відкинути відновлені документи Discard White Overprint in Output Скасувати накладення білого під час виведення @@ -2378,6 +2386,7 @@ file-filter Templates Шаблони Options for Selection… Параметри виділення… Options: Параметри: Options… Параметри… + Organic Fades Органічні згасання or more spaces with a tab або більше прогалин на табуляцію Order: Порядок: Ordinals Порядкові числівники @@ -2571,6 +2580,7 @@ diagnostics ui інтерфейс Presets: Набори: Press Натисніть Preview Перегляд + Preview: Перегляд: Preview All Layers Переглянути всі шари Preview in Browser Переглянути у браузері Preview in Browser needs the desktop app (the web downloads files instead) Перегляд у браузері вимагає настільної програми (web-версія завантажує файли) @@ -2665,6 +2675,8 @@ digits Malayalam Малаялам Range: Діапазон: Raster Effects Растрові ефекти Raster File Format: Формат растрового файлу: + Include Placed Images Включати розміщені зображення + Include Fill Hatches Включати штрихування заливок Raster Sample Size: Розмір растрового зразка: Raster/Vector Balance: Баланс растрових/векторних: Rasterize Растеризувати @@ -2846,6 +2858,10 @@ digits Malayalam Малаялам Replace Font Замінити шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замініть його іншим файлом або ігноруйте: зображення продовжать показувати попередній перегляд, збережений з документом. Replace Spine Замінити вісь + Create Spine Створити вісь + Create Spine: Створити вісь: + Always Завжди + Never Ніколи Replace Symbol Замінити символ Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом @@ -5672,6 +5688,8 @@ axis Y Axis Вісь Y Version: {version} Версія: {version} Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться File SHA-256: {hash} SHA-256 файлу: {hash} Read the objects it is given Читати передані йому об’єкти @@ -5734,6 +5752,12 @@ axis Y Axis Вісь Y @msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери @msg command {name}: {e} команда {name}: {e} +@msg manifest: {e} маніфест: {e} +@msg manifest: {e} at line {line} column {column} маніфест: {e} (рядок {line}, стовпець {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` має бути списком команд, кожна з `id` і `label` +@msg command {name} must be an object with an `id` and a `label` команда {name} має бути об'єктом з `id` і `label` +@msg missing field `{key}` немає поля `{key}` +@msg duplicate field `{key}` поле `{key}` вказано двічі @msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено @@ -6451,6 +6475,11 @@ breakText Glyphs Гліфи @msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту @msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Копіювати відомості для усунення несправностей +@msg Copied the troubleshooting info Відомості для усунення несправностей скопійовано +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Останній сеанс VectorCraft перестав відповідати перед закриттям. Довідка › Копіювати відомості для усунення несправностей копіює подробиці для звіту про помилку + # Safe Mode Safe Mode… Безпечний режим… Safe Mode Безпечний режим diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cb5aaf727..174b7c7dce 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -964,6 +964,14 @@ font-technology Variable Biến thiên Disable Auto Add/Delete Tắt tự động thêm/xóa Disable Opacity Mask Tắt mặt nạ độ mờ Discard Loại bỏ + Preserve Bảo lưu + TIFF (Black & White) TIFF (Đen trắng) + TIFF (8-bit Color) TIFF (Màu 8 bit) + High Resolution Độ phân giải cao + Medium Resolution Độ phân giải vừa + Low Resolution Độ phân giải thấp + Language Level 2 Cấp ngôn ngữ 2 + Language Level 3 Cấp ngôn ngữ 3 Discard Empty Type Loại bỏ chữ trống Discard Recovered Documents Loại bỏ tài liệu đã phục hồi Discard White Overprint in Output Loại bỏ in chồng màu trắng khi xuất @@ -2880,6 +2888,7 @@ diagnostics ui giao diện Press Nhấn Pressure Áp lực Preview Xem trước + Preview: Xem trước: Preview All Layers Xem trước tất cả lớp Preview in Browser Xem trước trong trình duyệt Preview in Browser needs the desktop app (the web downloads files instead) Xem trước trong trình duyệt cần ứng dụng máy tính để bàn (web sẽ tải tệp xuống thay thế) @@ -2954,6 +2963,7 @@ mark-style Japanese Kiểu Nhật Radial Blur… Mờ hướng tâm… Radial Dividers: Đường chia hướng tâm: Radial Glows Ánh sáng hướng tâm + Organic Fades Mờ dần tự nhiên Radial Gradient Chuyển sắc hướng tâm Conic Gradient Chuyển sắc hình nón Diamond Gradient Chuyển sắc hình thoi @@ -2971,6 +2981,8 @@ mark-style Japanese Kiểu Nhật Range: Phạm vi: Raster Effects Hiệu ứng raster Raster File Format: Định dạng tệp raster: + Include Placed Images Bao gồm ảnh đã đặt + Include Fill Hatches Bao gồm mẫu gạch của vùng tô Raster Sample Size: Kích thước mẫu raster: Raster/Vector Balance: Cân bằng raster/vector: Rasterize Raster hóa @@ -3128,6 +3140,10 @@ mark-style Japanese Kiểu Nhật Replace Font Thay thế phông chữ Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Thay thế bằng tệp khác hoặc bỏ qua: ảnh của nó vẫn hiển thị bản xem trước đã lưu cùng tài liệu. Replace Spine Thay thế trục + Create Spine Tạo trục + Create Spine: Tạo trục: + Always Luôn luôn + Never Không bao giờ Replace Symbol Thay thế ký hiệu Reset Transformation Đặt lại biến đổi Replace With Font Thay thế bằng phông chữ @@ -5673,6 +5689,8 @@ axis Y Axis Trục Y Version: {version} Phiên bản: {version} Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý File SHA-256: {hash} SHA-256 của tệp: {hash} Read the objects it is given Đọc các đối tượng được trao cho nó @@ -5735,6 +5753,12 @@ axis Y Axis Trục Y @msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái @msg command {name}: {e} lệnh {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (dòng {line}, cột {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` phải là một danh sách lệnh, mỗi lệnh có `id` và `label` +@msg command {name} must be an object with an `id` and a `label` lệnh {name} phải là một đối tượng có `id` và `label` +@msg missing field `{key}` thiếu trường `{key}` +@msg duplicate field `{key}` trường `{key}` bị lặp @msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt @@ -6452,6 +6476,11 @@ breakText Glyphs Glyph @msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions` @msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info Sao chép thông tin khắc phục sự cố +@msg Copied the troubleshooting info Đã sao chép thông tin khắc phục sự cố +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Phiên VectorCraft trước đã ngừng phản hồi trước khi đóng. Trợ giúp › Sao chép thông tin khắc phục sự cố sẽ sao chép chi tiết cho báo cáo lỗi + # Safe Mode Safe Mode… Chế độ an toàn… Safe Mode Chế độ an toàn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 286b3a0ca4..515600d033 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2253,6 +2253,7 @@ file-filter Templates 模板 Options 选项 Options: 选项: Options… 选项… + Organic Fades 自然渐隐 or more spaces with a tab 或多个空格加一个制表符 Order: 顺序: Ordinals 序数字 @@ -2445,6 +2446,7 @@ diagnostics ui 界面 Presets: 预设: Press 按 Preview 预览 + Preview: 预览: Preview in Browser 在浏览器中预览 Preview in Browser needs the desktop app (the web downloads files instead) 在浏览器中预览需要桌面版应用程序 (网页版会改为下载文件) Preview Mode 预览模式 @@ -2541,6 +2543,8 @@ digits Malayalam 马拉雅拉姆数字 Range: 范围: Raster Effects 栅格效果 Raster File Format: 栅格文件格式: + Include Placed Images 包含置入的图像 + Include Fill Hatches 包含填色的图案填充 Raster Sample Size: 栅格取样大小: Raster/Vector Balance: 栅格/矢量平衡: Rasterize 栅格化 @@ -2720,6 +2724,10 @@ digits Malayalam 马拉雅拉姆数字 Replace Font 替换字体 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 将其替换为其他文件,或忽略:其图像继续显示随文档保存的预览。 Replace Spine 替换脊线 + Create Spine 创建脊线 + Create Spine: 创建脊线: + Always 始终 + Never 从不 Replace Symbol 替换符号 Reset Transformation 重置变换 Replace With Font 替换为字体 @@ -5913,6 +5921,8 @@ axis Y Axis Y 轴 Version: {version} 版本:{version} Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装 File SHA-256: {hash} 文件 SHA-256:{hash} Read the objects it is given 读取交给它的对象 @@ -5975,6 +5985,12 @@ axis Y Axis Y 轴 @msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID @msg command {name}: {e} 命令 {name}:{e} +@msg manifest: {e} 清单:{e} +@msg manifest: {e} at line {line} column {column} 清单:{e}(第 {line} 行,第 {column} 列) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` 必须是命令列表,每个命令都有 `id` 和 `label` +@msg command {name} must be an object with an `id` and a `label` 命令 {name} 必须是包含 `id` 和 `label` 的对象 +@msg missing field `{key}` 缺少字段 `{key}` +@msg duplicate field `{key}` 字段 `{key}` 重复 @msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` @msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 @@ -6691,6 +6707,11 @@ breakText Glyphs 字形 @msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions` @msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info 复制故障排除信息 +@msg Copied the troubleshooting info 已复制故障排除信息 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 会话在关闭前停止了响应。帮助 › 复制故障排除信息 可复制用于错误报告的详细信息 + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c819eccd10..31472f0d4b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -791,6 +791,14 @@ font-technology Variable 可變 Disable Auto Add/Delete 停用自動增加/刪除 Disable Opacity Mask 停用不透明度遮色片 Discard 捨棄 + Preserve 保留 + TIFF (Black & White) TIFF (黑白) + TIFF (8-bit Color) TIFF (8 位元色彩) + High Resolution 高解析度 + Medium Resolution 中解析度 + Low Resolution 低解析度 + Language Level 2 語言層級 2 + Language Level 3 語言層級 3 Discard Empty Type 捨棄空白文字 Discard Recovered Documents 捨棄已復原的文件 Discard White Overprint in Output 輸出時捨棄白色疊印 @@ -3345,6 +3353,7 @@ file-filter Templates 範本 Options for Selection… 選取範圍的選項… Options: 選項: Options… 選項… + Organic Fades 自然漸隱 or more spaces with a tab 個以上的空格取代為定位字元 Order: 順序: Ordinals 序數字 @@ -3539,6 +3548,7 @@ diagnostics ui 介面 Presets: 預設集: Press 按下 Preview 預覽 + Preview: 預覽: Preview All Layers 預覽所有圖層 Preview in Browser 在瀏覽器中預覽 Preview in Browser needs the desktop app (the web downloads files instead) 在瀏覽器中預覽需要桌面版應用程式 (網頁版會改為下載檔案) @@ -3636,6 +3646,8 @@ digits Malayalam 馬來亞拉姆數字 Range: 範圍: Raster Effects 點陣效果 Raster File Format: 點陣檔案格式: + Include Placed Images 包含置入的影像 + Include Fill Hatches 包含填色的圖案填充 Raster Sample Size: 點陣取樣大小: Raster/Vector Balance: 點陣/向量平衡: Rasterize 點陣化 @@ -3817,6 +3829,10 @@ digits Malayalam 馬來亞拉姆數字 Replace Font 取代字體 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 以其他檔案取代,或忽略:其影像會繼續顯示隨文件儲存的預覽。 Replace Spine 取代脊線 + Create Spine 建立脊線 + Create Spine: 建立脊線: + Always 一律 + Never 永不 Replace Symbol 取代符號 Reset Transformation 重設變形 Replace With Font 取代為字體 @@ -5710,6 +5726,8 @@ axis Y Axis Y 軸 Version: {version} 版本:{version} Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝 File SHA-256: {hash} 檔案 SHA-256:{hash} Read the objects it is given 讀取交給它的物件 @@ -5772,6 +5790,12 @@ axis Y Axis Y 軸 @msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID @msg command {name}: {e} 指令 {name}:{e} +@msg manifest: {e} 資訊清單:{e} +@msg manifest: {e} at line {line} column {column} 資訊清單:{e}(第 {line} 行,第 {column} 欄) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` 必須是指令清單,每個指令都有 `id` 和 `label` +@msg command {name} must be an object with an `id` and a `label` 指令 {name} 必須是包含 `id` 和 `label` 的物件 +@msg missing field `{key}` 找不到欄位 `{key}` +@msg duplicate field `{key}` 欄位 `{key}` 重複 @msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` @msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 @@ -6489,6 +6513,11 @@ breakText Glyphs 字符 @msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions` @msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget` +# Copy Troubleshooting Info + Copy Troubleshooting Info 複製疑難排解資訊 +@msg Copied the troubleshooting info 已複製疑難排解資訊 +@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 工作階段在關閉前停止回應。說明 › 複製疑難排解資訊 可複製用於錯誤回報的詳細資料 + # Safe Mode Safe Mode… 安全模式… Safe Mode 安全模式 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 3ca34cdd77..4bdee0a6b6 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -215,16 +215,24 @@ fn permission_words(name: &str, commands: &[String]) -> String { /// place of another one with its id and get its settings and cache (`replace`); OK installs it if /// the module still has the hash the prompt showed, Cancel installs nothing → `{pending}` while it /// asks. The `grant` and `allowHeavy` of a request are ignored: only the dialog's OK approves. +/// Every question opens the approval dialog ([`dialogs::plugin::ask_install`]). When automation +/// asked ([`VectorcraftApp::automation_asked`]), the question has no OK that installs, its +/// `replace: true` doesn't skip asking about replacing, and the answer while it asks adds +/// `approval: "personMustStartInstall"`: the person starts Install Plug-in… to install it. /// Every text from the manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { + let origin = if app.automation_asked() { dialogs::plugin::InstallOrigin::Automation } else { dialogs::plugin::InstallOrigin::Person }; + let automated = origin == dialogs::plugin::InstallOrigin::Automation; let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect()); let info = app.run("plugin.inspect", source)?; let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy"); - // `replace: false` asks for no replacing, so only a module without `replace` asks. - let asks_replace = module.get("replace").is_none_or(Value::is_null) && info["needsReplace"] == true; + // `replace: false` asks for no replacing, so only a module without `replace` asks, or one whose + // `replace: true` automation gave. + let replace = module.get("replace").filter(|v| !v.is_null()); + let asks_replace = info["needsReplace"] == true && replace.is_none_or(|r| automated && *r == true); if !needs_grant && !heavy && !asks_replace { let r = app.run("plugin.install", install)?; report_installed(app, &r); @@ -245,13 +253,8 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result Result Result Option { /// File → Open… pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let path = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e636f0520a..be668e1e4f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,7 @@ pub mod font_menu; mod frame_rate; mod free_transform; pub mod graphics; +pub mod hang_beacon; pub mod i18n; pub mod icon_data; pub mod icons; @@ -80,6 +81,7 @@ pub mod toolbar; mod tools_float; pub mod touch; pub mod trace_runs; +pub mod troubleshooting; mod ui_fonts; pub mod unsaved; pub mod updates_ui; @@ -120,7 +122,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, Services, + WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; @@ -228,6 +231,9 @@ pub struct VectorcraftApp { /// The graphics adapter the window renders with ("name (backend)"), as the host reports it: /// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown. pub graphics_adapter: Option, + /// Where [`Self::run`] marks each command for the desktop app's hang report: + /// [`hang_beacon::BEACON`], or a test's own. + pub(crate) beacon: &'static hang_beacon::Beacon, /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, /// The Paste commands can paste from the system clipboard alone: it holds something to paste diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2b24124e1b..1698e06ed4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,7 +77,7 @@ pub(crate) use labels::lists_plugin; use labels::shows_a_name; pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; pub use run::{check_params, run_ui_command}; -pub(crate) use run::{normalize_panel, opt_bool}; +pub(crate) use run::{normalize_panel, opt_bool, opt_index, opt_number}; #[cfg(test)] use slots::RECENT_FONT_IDS; pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state}; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 3030ef2c61..b5e2a3e3b4 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -25,6 +25,34 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { } } +/// An optional `{key?: number}` param: none when it is omitted or null. +pub(crate) fn opt_number(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| format!("`{key}` must be a number")), + } +} + +/// An optional `{key?: index}` param, a whole number of 0 or more: none when it is omitted or null. +pub(crate) fn opt_index(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| format!("`{key}` must be a whole number of 0 or more")), + } +} + +/// An optional `{key?: [x, y]}` param: none when it is omitted or null. +fn opt_point(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_array().map(Vec::as_slice) { + Some([x, y]) => x.as_f64().zip(y.as_f64()).map(|(x, y)| Some(vectorcraft_geom::Point::new(x, y))), + _ => None, + } + .ok_or_else(|| format!("`{key}` must be [x, y] numbers")), + } +} + /// Handle a UI command. `None` = not a UI command (the engine handles it). /// `params` has only keys the params description of engine command `id` names, or the parameter /// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their @@ -48,13 +76,18 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { // Window menu items and the named panel function keys toggle against the checked state; // other commands that open panels show them. - if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { + let toggles = if id == "window.panel" { opt_bool(p, "toggle").and_then(|t| Ok((t, opt_bool(p, "toggleGroup")?))) } else { Ok((None, None)) }; + let (toggle, toggle_group) = match toggles { + Ok(flags) => flags, + Err(e) => return Some(Err(e)), + }; + if toggle == Some(true) && checked(app, id, p) == Some(true) { let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); if app.ui.open_panel.as_deref() == Some(panel) { app.ui.open_panel = None; return Some(Ok(json!({"open": null}))); } - if p.get("toggleGroup").and_then(Value::as_bool) == Some(true) { + if toggle_group == Some(true) { return Some(crate::panel_docking::hide_group(app, panel)); } return Some(app.run("window.panel.close", json!({"panel": panel}))); @@ -131,7 +164,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::new_from_template(app, s("path")), - "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), + "file.revert" if opt_bool(p, "confirmed") != Ok(Some(true)) => opt_bool(p, "confirmed").and_then(|_| io::ask_revert(app)), "file.reveal" => io::reveal(app), "file.cancelExport" => Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), @@ -386,9 +419,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let print = app.session.prefs.display_print_size; - let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); - let center = - p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let (zoom, center) = match opt_number(p, "zoom").and_then(|z| Ok((z, opt_point(p, "center")?))) { + Ok(given) => given, + Err(e) => return Some(Err(e)), + }; + let z = crate::state::zoom_from_display_percent(zoom.unwrap_or(100.0), print); match app.view_mut() { Some(v) => { v.zoom = z.clamp(0.0313, 640.0); @@ -417,7 +452,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { + let mode = match opt_index(p, "mode") { + Ok(mode) => mode, + Err(e) => return Some(Err(e)), + }; + app.ui.screen_mode = match mode { Some(m) => m.min(3) as u8, // F cycles the three screen modes. Without a mode, the command also goes from // Presentation Mode (not in the cycle) back to Normal. @@ -674,6 +713,7 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), "help.safeMode" => crate::dialogs::safe_mode::open(app), + "help.copyTroubleshootingInfo" => crate::troubleshooting::copy(app), "help.commandPalette" => { app.ui.palette_open = !app.ui.palette_open; app.ui.palette_query.clear(); @@ -682,9 +722,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match p.get("index").and_then(Value::as_u64).map(|i| i as usize) { - Some(i) => crate::unsaved::close(app, i), - None => { + "file.close" => match opt_index(p, "index") { + Err(e) => Err(e), + Ok(Some(i)) => crate::unsaved::close(app, i), + Ok(None) => { app.session.active_index()?; crate::doc_windows::close_current(app) } @@ -696,8 +737,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("missing `name`".into()), }, "ui.newSwatch" => { - let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) + let on = |k: &str| opt_bool(p, k).map(|on| on.unwrap_or(false)); + on("spot") + .and_then(|spot| Ok((spot, on("global")?))) + .and_then(|(spot, global)| crate::dialogs::new_swatch::open(app, spot, global, s("group").as_deref())) } "ui.newColorGroup" => { let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); @@ -720,7 +763,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Para), "ui.symbolOptions" => { let name = s("name"); - let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); + let edit = match opt_bool(p, "edit") { + Ok(edit) => edit.unwrap_or(false), + Err(e) => return Some(Err(e)), + }; if edit || name.is_some() { crate::dialogs::symbol_options::open_edit(app, name.as_deref()) } else { @@ -923,9 +969,8 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), "ui.batchDialog" => { - let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; - let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; - crate::dialogs::action_batch::open(app, set, action) + let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0)); + index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action)) } crate::panels::actions::SAVE_SET => crate::panels::actions::save_set(app, p), crate::panels::actions::LOAD_SET => crate::panels::actions::load_set(app, p), @@ -958,7 +1003,11 @@ fn run_ui_part12(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { + let multiple = match opt_bool(p, "multiple") { + Ok(multiple) => multiple.unwrap_or(false), + Err(e) => return Some(Err(e)), + }; + app.run("assets.add", json!({ "multiple": multiple })).map(|r| { let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); json!({ "assets": ids }) diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 916b8b311a..59f4752fbf 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -721,6 +721,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Search Commands…", "help.commandPalette"), todos("VectorCraft Help…", "F1"), c("Safe Mode…", "help.safeMode"), + c("Copy Troubleshooting Info", "help.copyTroubleshootingInfo"), Sep, c("Check for Updates…", "help.checkForUpdates"), c("About VectorCraft", "help.about"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 19a6a1da67..08ce08873a 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -749,6 +749,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", ), + ( + "help.copyTroubleshootingInfo", + "Copy Troubleshooting Info", + "", + "{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions", @@ -1524,7 +1530,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked | null on the web without path or dataBase64, once the browser's file picker opens (the picked file is handled as above)", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/panels/blend.rs b/crates/ui-egui/src/panels/blend.rs index e258cfbc09..a41c2946c7 100644 --- a/crates/ui-egui/src/panels/blend.rs +++ b/crates/ui-egui/src/panels/blend.rs @@ -1,13 +1,17 @@ -//! Blend panel (Window → Blend): Make, the Spacing and Orientation of the selected blend (with no -//! blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, -//! Replace Spine, Expand and Release. Each control runs its Object → Blend command -//! (`object.blend.make`, `object.blend.options` and the others), so a change of the selected -//! blend's spacing or orientation shows on the canvas at once as one undo step. Make is dimmed -//! until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two -//! or more objects are selected, and the other buttons are dimmed until a blend is selected. +//! Blend panel (Window → Blend): Make, Create Spine (whether new blends get a spine), the Spacing +//! and Orientation of the selected blend (with no blend selected, the options new blends start +//! with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release. Each control +//! runs its Object → Blend command (`object.blend.make`, `object.blend.createSpine`, +//! `object.blend.options` and the others), so a change of the selected blend's spacing or +//! orientation shows on the canvas at once as one undo step. Make is dimmed until two or more +//! objects are selected. Create Spine is always available. Spacing and Orientation are dimmed until +//! a blend or two or more objects are selected, and the other buttons are dimmed until a blend is +//! selected. Easing and Reverse Spine are dimmed for a blend made without a spine. use egui::Ui; use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_doc::live::BlendSpineMode; use super::selection_len; use crate::VectorcraftApp; @@ -38,10 +42,18 @@ fn buttons(app: &mut VectorcraftApp, ui: &mut Ui, row: &[(&str, &str)]) { }); } -/// Can `cmd` run on the selection? Make needs two or more objects, and the other commands need a -/// selected blend. +/// Can `cmd` run on the selection? Make needs two or more objects, the other commands need a +/// selected blend, and Reverse Spine needs every selected blend to have a spine. fn available(app: &VectorcraftApp, cmd: &str) -> bool { - crate::menus::enabled(app, cmd) && (cmd != "object.blend.make" || selection_len(app) >= 2) + crate::menus::enabled(app, cmd) + && (cmd != "object.blend.make" || selection_len(app) >= 2) + && (cmd != "object.blend.reverseSpine" || !selects_blend_without_spine(app)) +} + +/// Is a blend made without a spine selected? +fn selects_blend_without_spine(app: &VectorcraftApp) -> bool { + let Ok(st) = app.session.doc() else { return false }; + st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) } /// `object.blend.options` parameters for spacing mode `mode`, with the step count and distance @@ -95,8 +107,9 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } }); let selected = info["target"] == "blend"; - // Position easing needs a spine to run along: a replaced one, or Align to Path. - let spined = info["spine"]["explicit"] == true || info["orientation"] == "path"; + // Position easing needs a spine to run along: a replaced one, or Align to Path. A blend made + // without a spine has none. + let spined = info["hasSpine"] != false && (info["spine"]["explicit"] == true || info["orientation"] == "path"); ui.add_enabled_ui(selected && spined, |ui| { let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"]; let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")]; @@ -129,9 +142,30 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Create Spine: whether new blends get a spine. It shows the setting new blends take, whatever is +/// selected, and a change runs `object.blend.createSpine`; blends already made keep theirs. +fn create_spine(app: &mut VectorcraftApp, ui: &mut Ui) { + let current = app.session.prefs.blend_options.unwrap_or_default().create_spine; + let modes = BlendSpineMode::ALL; + let labels = [tl!("Auto"), tl!("Always"), tl!("Never")]; + let shown = modes.iter().position(|m| *m == current).and_then(|i| labels.get(i)).copied().unwrap_or(labels[0]); + let mut change = None; + widgets::label_row(ui, tl!("Create Spine:"), LABEL_W, |ui| { + if let Some(mode) = + widgets::dropdown(ui, "blend-panel-create-spine", shown, &labels, ui.available_width().max(FIELD_W)).and_then(|i| modes.get(i)) + { + change = Some(*mode); + } + }); + if let Some(mode) = change { + app.run("object.blend.createSpine", json!({ "mode": mode.id() })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let blend = crate::menus::enabled(app, "object.blend.release"); buttons(app, ui, &[(tl!("Make"), "object.blend.make")]); + create_spine(app, ui); widgets::divider(ui); ui.add_enabled_ui(blend || selection_len(app) >= 2, |ui| settings(app, ui)); widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| buttons(app, ui, &[(tl!("Reverse Front to Back"), "object.blend.reverseFrontToBack")])); diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index ac92a42793..78ce76da32 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -827,8 +827,7 @@ pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result, filters: Vec<(&'static str, &'static [&'static str])>, ) -> Result, String> { - if path.is_none() - && let Some(f) = app.services.open_async.as_mut() - { - f(); + if path.is_none() && crate::picks::open_async(app) { return Ok(None); } path.or_else(|| { diff --git a/crates/ui-egui/src/panels/tests_blend.rs b/crates/ui-egui/src/panels/tests_blend.rs index b79823237b..46521d3a65 100644 --- a/crates/ui-egui/src/panels/tests_blend.rs +++ b/crates/ui-egui/src/panels/tests_blend.rs @@ -1,8 +1,8 @@ //! The Blend panel drawn headless: Window › Blend opens it, and its controls do nothing until the //! selection allows them. Make needs two or more objects, Spacing and Orientation need a blend or -//! two or more objects, and the other buttons need a blend. Make blends the selection, Spacing and -//! Orientation edit the selected blend through `object.blend.options`, and each other button runs -//! its Object › Blend command. +//! two or more objects, and the other buttons need a blend. Make blends the selection, Create Spine +//! sets whether new blends get a spine, Spacing and Orientation edit the selected blend through +//! `object.blend.options`, and each other button runs its Object › Blend command. use egui::{Event, Pos2, Rect, pos2, vec2}; use serde_json::json; @@ -39,6 +39,11 @@ impl Frames { /// The center of the text `label` the panel draws. fn text(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 { + self.find_text(app, label).unwrap_or_else(|texts| panic!("no {label:?} in {texts:?}")) + } + + /// The center of the text `label` the panel draws, or every text it draws. + fn find_text(&mut self, app: &mut VectorcraftApp, label: &str) -> Result> { self.run(app, vec![]); let out = self.run(app, vec![]); let mut texts = vec![]; @@ -50,7 +55,7 @@ impl Frames { } } out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); - texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")).1.center() + texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).ok_or(texts) } /// The center of the button that screen readers name `label`. @@ -124,7 +129,19 @@ fn window_blend_opens_the_panel() { crate::menus::invoke(&mut app, "window.panel", item.params.clone()); assert!(crate::panel_docking::showing(&app.ui, ID)); let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show_icon_panel(app, ui, ID)); - for label in ["Make", "Spacing:", "Orientation:", "Direction:", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] { + for label in [ + "Make", + "Create Spine:", + "Auto", + "Spacing:", + "Orientation:", + "Direction:", + "Reverse Front to Back", + "Reverse Spine", + "Replace Spine", + "Expand", + "Release", + ] { assert!(text.contains(label), "{label} in {text}"); } assert!(!text.contains("on the roadmap"), "{text}"); @@ -226,6 +243,39 @@ fn each_button_runs_its_blend_command() { assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}"); } +#[test] +fn create_spine_sets_what_new_blends_get_whatever_is_selected() { + let (mut app, id) = blended(); + let (before, depth) = (spec(&app, id), undo_depth(&app)); + let mode = |app: &mut VectorcraftApp| app.session.execute("object.blend.createSpine", &json!({})).unwrap()["mode"].clone(); + let mut f = Frames::new(); + f.text(&mut app, "Create Spine:"); + f.click_text(&mut app, "Auto"); + f.click_text(&mut app, "Never"); + assert_eq!(mode(&mut app), "never"); + assert_eq!((spec(&app, id), undo_depth(&app)), (before, depth), "the selected blend and the undo history stay as they were"); + f.text(&mut app, "Never"); + // Nothing selected: the dropdown still shows and sets the setting. + app.run("select.none", json!({})).unwrap(); + f.click_text(&mut app, "Never"); + f.click_text(&mut app, "Always"); + assert_eq!(mode(&mut app), "always"); + f.click_text(&mut app, "Always"); + f.click_text(&mut app, "Never"); + // A blend made now has no spine: Reverse Spine and easing are dimmed for it. + app.run("edit.undo", json!({})).unwrap(); + app.run("select.all", json!({})).unwrap(); + f.click_text(&mut app, "Make"); + let (g, blend) = top(&app)[0]; + assert!(blend && spec(&app, g).no_spine, "{:?}", top(&app)); + let made = spec(&app, g); + let depth = undo_depth(&app); + f.click_text(&mut app, "Reverse Spine"); + f.click_text(&mut app, "Ease In"); + assert!(f.find_text(&mut app, "Ease Out In").is_err(), "the Type menu did not open"); + assert_eq!((spec(&app, g), undo_depth(&app), app.ui.status.as_str()), (made, depth, ""), "nothing ran"); +} + #[test] fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() { // Type and Ramp need a spine: Align to Path gives the blend one. diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..7699516d78 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -105,6 +105,9 @@ struct Waiting { /// The automation roots in force when it was asked for: the entry runs again under them, not /// with the desktop's access of the frame the answer comes in. scope: ScopeHandle, + /// Automation asked for it ([`VectorcraftApp::automation_asked`]): the entry runs again as + /// automation's ([`as_automated`]). + automated: bool, } /// The app's file dialogs ([`VectorcraftApp`]'s `picks`). @@ -122,6 +125,13 @@ pub(crate) struct Picks { replacing: Option, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option, + /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). + automated: bool, + /// The web's file pickers opened ([`open_async`]) that have not answered yet + /// (`Services::picked_inbox`). + web_opens: u32, + /// Automation opened one of them: their answers are automation's. + web_open_automated: bool, } /// A save dialog's answer that names, once the app gave it an extension, a file that is there. @@ -152,6 +162,23 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.automated = false; + } + + /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). + pub(crate) fn automated(&self) -> bool { + self.automated + } + + /// One of the web's file pickers answered (a file, or canceled): whether the answer is + /// automation's. Once none is waiting, the next one the person opens answers for the person. + pub(crate) fn answer_web_open(&mut self) -> bool { + let automated = self.web_open_automated; + self.web_opens = self.web_opens.saturating_sub(1); + if self.web_opens == 0 { + self.web_open_automated = false; + } + automated } /// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`). @@ -188,6 +215,29 @@ pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run `f` as handling the answer of a file dialog automation opened when `automated` (and inside +/// one already, as that): [`VectorcraftApp::automation_asked`] is true meanwhile. +pub(crate) fn as_automated(app: &mut VectorcraftApp, automated: bool, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.automated; + app.picks.automated = outer || automated; + let r = f(app); + app.picks.automated = outer; + r +} + +/// Opens the web's file picker, whose file arrives later through `Services::picked_inbox`, noting +/// whether automation asked for it ([`VectorcraftApp::automation_asked`]) → false when the host has +/// none. While a picker automation opened has not answered, every answer is automation's, also one +/// to a picker the person opened. +pub(crate) fn open_async(app: &mut VectorcraftApp) -> bool { + let automated = app.automation_asked(); + let Some(f) = app.services.open_async.as_mut() else { return false }; + f(); + app.picks.web_opens += 1; + app.picks.web_open_automated |= automated; + true +} + /// [`as_entry`] for a panel's or a dialog's button that `f` handles. pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { let f = Rc::new(f); @@ -319,8 +369,8 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option { match (app.picks.entry.clone(), app.services.start_pick.as_mut()) { (Some(entry), Some(start)) => { if let Some(answer) = start(request.clone()) { - let doc = entry.document(app); - app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture() }); + let (doc, automated) = (entry.document(app), app.automation_asked()); + app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture(), automated }); return None; } } @@ -367,7 +417,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { // The dialog's thread is gone: as cancelled. Err(TryRecvError::Disconnected) => vec![], }; - let Some(Waiting { request, entry, doc, scope, .. }) = app.picks.waiting.take() else { return }; + let Some(Waiting { request, entry, doc, scope, automated, .. }) = app.picks.waiting.take() else { return }; app.picks.refused = false; // Once more: what waited for the dialog (files from the Finder, the menu bar) goes on. ctx.request_repaint(); @@ -379,7 +429,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } app.picks.answer = Some((request, answer)); - rerun(app, entry); + as_automated(app, automated, |app| rerun(app, entry)); // An entry that took another way this time leaves the answer unused. app.picks.answer = None; }); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 443d4aad3f..f6188babca 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -105,8 +105,8 @@ fn write(app: &mut VectorcraftApp, store: &Arc, pick: impl Fn let mut started = 0; for job in jobs { let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); - let (done, store) = (job.clone(), store.clone()); - let work = move |_: Writer| job.write(store.as_ref()); + let done = job.clone(); + let work = move |_: Writer| job.write(); let then = move |app: &mut VectorcraftApp, r: Result| { let r = r?; done.finish(&mut app.session); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..de038296ab 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -47,6 +47,9 @@ pub type ExistsFn = Box bool>; pub type Inbox = Arc)>>>; +/// The web open picker's answers in order: the picked file, or `None` when it was canceled. +pub type PickedInbox = Arc)>>>>; + /// Starts a browser download of the bytes under a file name; an error when it couldn't be started /// (the save or export then fails as a refused write does: #1693). pub type DownloadFn = Box Result<(), String>>; @@ -89,11 +92,17 @@ pub struct Services { /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). /// Without it, no file counts as there. pub exists: Option, - /// Files that arrived asynchronously (web open / drops): (name, bytes). + /// Files that arrived asynchronously (web drops): (name, bytes). pub inbox: Option, + /// Web: the open picker's answers (`open_async`). A file opens as automation's when automation + /// opened a picker that has not answered yet. + pub picked_inbox: Option, + /// Web: files the page's script handed over (`window.vectorcraft.openFile`). They open as + /// automation's. + pub page_inbox: Option, /// Web: trigger a browser download instead of writing a path. pub download: Option, - /// Web: start an async open (bytes arrive via `inbox`). + /// Web: start an async open (bytes arrive via `picked_inbox`). pub open_async: Option>, /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with /// egui's Paste event (web, and keyboard paste everywhere). @@ -179,6 +188,10 @@ pub struct Services { /// reports for a window on that monitor. Empty on the web, and where the host cannot place a /// window by these points, which sends floating panels to the main window's monitor. pub dock_monitors: Vec, + /// The text Help › Copy Troubleshooting Info copies (desktop: the platform, the graphics + /// processor, recent commands and frame times, and the hang reports of this session and the + /// one before). Without it the command copies the version only ([`crate::troubleshooting`]). + pub troubleshooting: Option String>>, } /// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index ee876b2e71..df2fe29912 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -630,9 +630,11 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() { let _ = std::fs::remove_dir_all(base); } -/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes -/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs, -/// and a frame of synthetic input can't press its OK; the person at the keyboard can. +/// A plug-in's permissions are approved only by a person who asked for the install. A control +/// request (or MCP, which comes through it) can't pass `grant`, can't answer the approval dialog or +/// rewrite what it installs, and a frame of synthetic input can't press its OK. The person can't +/// approve the question automation asked either: they start Install Plug-in… themselves, which asks +/// again, and its OK installs. #[test] fn automation_cannot_approve_a_plugin_install() { let (tx, rx) = std::sync::mpsc::channel(); @@ -650,8 +652,9 @@ fn automation_cannot_approve_a_plugin_install() { let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); - refused(&r, "Only the person at the keyboard"); + refused(&r, ASKED); assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); @@ -662,22 +665,293 @@ fn automation_cannot_approve_a_plugin_install() { let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); assert!(r.get("error").is_none(), "{r}"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); - refused(&r, "Only the person at the keyboard"); + refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); - // Shown for long enough for a person's OK, it still refuses automation. + // Shown for long enough for a person's OK, it still refuses automation, and the person too. + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, ASKED); + app.synthetic_frame = true; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + app.synthetic_frame = false; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "a person's OK on automation's question"); + assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + + // The person starts Install Plug-in… with the file: a new question, which a person's OK approves + // once it was shown for the delay, and which automation still can't. + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question"); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["pending"], crate::dialogs::plugin::INSTALL_KIND, "not shown yet"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, "Only the person at the keyboard"); - app.synthetic_frame = true; - assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard")); - assert!(vectorcraft_plugins::registry::get(id).is_none()); - app.synthetic_frame = false; - crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// The refusal of an OK on an install question automation asked. +const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; + +/// The approval module `id` that lists `doc.read`, and its bytes as base64. +fn approval_module(id: &str) -> (Vec, String) { + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + (bytes, data) +} + +/// An install question that a control request asked keeps OK disabled in the person's window, even +/// once it was shown for the delay: a person's click, Enter or Space install nothing, and the dialog +/// says to start Install Plug-in…. Cancel still closes it at once. +#[test] +fn a_person_cannot_approve_an_install_question_automation_asked() { + use super::plugins::{at, click_ok, ok_enabled, waiting, window}; + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.asked"; + let (tx, rx) = std::sync::mpsc::channel(); + let app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let mut h = window(app); + let (_, data) = approval_module(id); + let r = request(h.state_mut(), &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(!ok_enabled(&h), "OK stays disabled past the delay"); + assert!(h.query_by_label(ASKED).is_some(), "the dialog says what to do"); + click_ok(&mut h, 10.7); + at(&mut h, 10.8); + assert!(waiting(h.state(), id), "a person's click"); + for (key, time) in [(egui::Key::Enter, 10.9), (egui::Key::Space, 11.0)] { + h.key_press(key); + at(&mut h, time); + assert!(waiting(h.state(), id), "{key:?}"); + } + assert!(crate::dialogs::confirm(h.state_mut()).unwrap_err().contains(ASKED), "a confirm that skips the button"); + assert!(waiting(h.state(), id)); + h.get_by_label("Cancel").click(); + at(&mut h, 11.1); + assert!(h.state().ui.dialog.is_none() && h.state().pending_plugin_install.is_none(), "Cancel"); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); +} + +/// A question automation asks in place of the one a person asked can't be approved: it keeps +/// automation's origin, though the person's question was shown and ready. +#[test] +fn automation_replacing_a_persons_install_question_does_not_take_its_approval() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let (mine, theirs) = ("test.ui.approval.mine", "test.ui.approval.theirs"); + app.run("ui.installPlugin", json!({"dataBase64": approval_module(mine).1})).unwrap(); + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(theirs).1}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + for id in [mine, theirs] { + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none(), "{id}"); + } + // Asked again by automation with the same file, it is still automation's. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(mine).1}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + assert!(vectorcraft_plugins::registry::get(mine).is_none()); +} + +/// A file a person picks in the file dialog that a control request opened (Install Plug-in… or +/// File › Open with no path) is automation's request: its question can't be approved. The file +/// dialog a person opens asks a question they can approve. On the desktop the dialog answers later +/// ([`crate::picks`]); on the web the file arrives through the picker's inbox, apart from drops and +/// the page's script, which is automation. +#[test] +fn a_file_picked_in_a_dialog_automation_opened_stays_automations() { + let base = std::env::temp_dir().join(format!("vc-ui-approval-picked-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let (desktop, web) = ("test.ui.approval.picked", "test.ui.approval.picked.web"); + let (bytes, _) = approval_module(desktop); + let wasm = base.join("picked.wasm"); + std::fs::write(&wasm, &bytes).unwrap(); + let mut services = Services::default(); + let (shown, _) = super::picks::off_the_ui_thread(&mut services); + services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let pick = |app: &mut VectorcraftApp| { + shown.answer(&[&s(wasm.clone())]); + crate::picks::poll(app, &egui::Context::default()); + }; + for command in ["ui.installPlugin", "file.open"] { + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": command, "params": {}})); + assert!(app.file_dialog_open(), "{command}: {r}"); + pick(&mut app); + assert!(super::plugins::waiting(&app, desktop), "{command}: {:?}", app.ui.status); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{command}"); + crate::dialogs::cancel(&mut app); + } + let _ = app.run("ui.installPlugin", json!({})); + assert!(app.file_dialog_open()); + pick(&mut app); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(desktop).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + app.run("plugin.remove", json!({"id": desktop})).unwrap(); + + // The web: the browser's file picker answers through its own inbox, apart from the files a + // person drops and the ones the page's script hands over. + let (dropped, page) = <(crate::services::Inbox, crate::services::Inbox)>::default(); + let picked = crate::services::PickedInbox::default(); + let services = Services { + open_async: Some(Box::new(|| {})), + inbox: Some(dropped.clone()), + picked_inbox: Some(picked.clone()), + page_inbox: Some(page.clone()), + ..Default::default() + }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let (by_drop, by_page) = ("test.ui.approval.picked.dropped", "test.ui.approval.picked.page"); + let file = |id: &str| (format!("{id}.wasm"), approval_module(id).0); + let hand_over = |app: &mut VectorcraftApp, inbox: &crate::services::Inbox, id: &str| { + inbox.lock().unwrap().push(file(id)); + app.drain_inbox(); + }; + // The picker answers with a file, or `None` when it is canceled. + let answer = |app: &mut VectorcraftApp, id: Option<&str>| { + picked.lock().unwrap().push(id.map(file)); + app.drain_inbox(); + }; + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}})); + assert!(r.get("error").is_none(), "{r}"); + // A file the person drops before the picker answers stays the person's. + hand_over(&mut app, &dropped, by_drop); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(by_drop).is_some()); + answer(&mut app, Some(web)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the web"); + crate::dialogs::cancel(&mut app); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(web)); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(web).is_some()); + // Opening the picker again as the person before it answers automation leaves the answer + // automation's. + let (again, after) = ("test.ui.approval.picked.again", "test.ui.approval.picked.after"); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}})); + assert!(r.get("error").is_none(), "{r}"); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(again)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "opened again"); + crate::dialogs::cancel(&mut app); + // Once the other picker is canceled, the next one the person opens answers for them again. + answer(&mut app, None); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(after)); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(after).is_some()); + // The page's script is automation. + hand_over(&mut app, &page, by_page); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the page's script"); + crate::dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::granted(by_page).is_none()); + for id in [web, by_drop, after] { + app.run("plugin.remove", json!({"id": id})).unwrap(); + } + let _ = std::fs::remove_dir_all(base); +} + +/// A `.wasm` that a control request opens by path (`app.open`) asks a question that can't be +/// approved; one the person opens can be. +#[test] +fn a_plugin_automation_opened_by_path_asks_a_question_no_one_approves() { + let base = std::env::temp_dir().join(format!("vc-ui-approval-open-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let id = "test.ui.approval.opened"; + let (bytes, _) = approval_module(id); + let wasm = base.join("opened.wasm"); + std::fs::write(&wasm, &bytes).unwrap(); + let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let r = request(&mut app, &tx, "app.open", json!({"path": s(wasm.clone())})); + assert!(r.get("error").is_none(), "{r}"); + assert!(super::plugins::waiting(&app, id)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + crate::dialogs::cancel(&mut app); + crate::io::open_path(&mut app, &s(wasm.clone())).unwrap(); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(id).is_some()); + app.run("plugin.remove", json!({"id": id})).unwrap(); + let _ = std::fs::remove_dir_all(base); +} + +/// A module that takes the place of another with its id, and asks for nothing else: automation's +/// `replace: true` doesn't skip the question and its question can't be approved. The question a +/// person asked keeps what it installs out of reach of `ui.dialog.set`, refuses a control confirm, +/// and the person's own OK replaces. +#[test] +fn automation_cannot_approve_replacing_a_plugin() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let id = "test.ui.approval.replaced"; + let plain = |output: &str| { + wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{id}","name":"Plain","kind":"filter"}}"#), output)).unwrap() + }; + let (first, other) = (plain(r#"{"objects":[]}"#), plain(r#"{"objects":[],"error":null}"#)); + let installed = || vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()); + let first_hash = vectorcraft_plugins::permissions::sha256_hex(&first); + let data = vectorcraft_format::base64_encode(&other); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&first)})).unwrap(); + assert_eq!(installed(), Some(first_hash.clone())); + + for params in [json!({"dataBase64": data}), json!({"dataBase64": data, "replace": true})] { + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{params}: {r}"); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{params}: {r}"); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{params}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + assert!(r["error"].as_str().is_some_and(|e| e.contains(ASKED)), "{r}"); + crate::dialogs::cancel(&mut app); + assert_eq!(installed(), Some(first_hash.clone()), "{params}"); + } + + // The person's question: a control request can't point its OK at other bytes, nor press it. + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); + let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); + assert!(r.get("error").is_none(), "{r}"); + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); + assert_eq!(installed(), Some(first_hash.clone())); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(installed(), Some(vectorcraft_plugins::permissions::sha256_hex(&other)), "the bytes the question showed"); + app.run("plugin.remove", json!({"id": id})).unwrap(); + // A module that asks for nothing still installs at once when automation asks. + let free = "test.ui.approval.free"; + let module = wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{free}","name":"Free","kind":"filter"}}"#), "{}")).unwrap(); + let params = json!({"dataBase64": vectorcraft_format::base64_encode(&module)}); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params})); + assert!(r["result"]["pending"].is_null() && app.ui.dialog.is_none(), "{r}"); + assert!(vectorcraft_plugins::registry::get(free).is_some()); + app.run("plugin.remove", json!({"id": free})).unwrap(); +} + type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>; /// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken @@ -702,10 +976,10 @@ fn still_waiting(h: &DialogHarness, id: &str) -> bool { && vectorcraft_plugins::registry::granted(id).is_none() } -/// Keys and pointer input sent through the control path approve nothing at the open install -/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a -/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on -/// the same button approves. +/// Keys and pointer input sent through the control path approve nothing at the install approval a +/// person asked for: Enter and Space press nothing with no button focused, and with OK focused they +/// and a `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click +/// on the same button approves. #[test] fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { use egui_kittest::kittest::{NodeT, Queryable}; @@ -717,8 +991,8 @@ fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); let data = vectorcraft_format::base64_encode(&bytes); - let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); - assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question"); let mut fonts = false; let mut h: DialogHarness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 105c223261..0a80254db0 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -45,11 +45,11 @@ pub(super) fn show_approval(app: &mut VectorcraftApp) { panic!("the approval's OK never took input"); } -type Window = egui_kittest::Harness<'static, VectorcraftApp>; +pub(super) type Window = egui_kittest::Harness<'static, VectorcraftApp>; /// The dialog layer of `app` in a window, each frame drawn at the time [`at`] gives it. Building it /// draws frames at times of its own, so a test opens the dialog after. -fn window(app: VectorcraftApp) -> Window { +pub(super) fn window(app: VectorcraftApp) -> Window { let mut fonts = false; egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { @@ -64,26 +64,26 @@ fn window(app: VectorcraftApp) -> Window { } /// Draws a frame of `h` at `time` seconds. -fn at(h: &mut Window, time: f64) { +pub(super) fn at(h: &mut Window, time: f64) { h.input_mut().time = Some(time); h.step(); } /// Whether the approval's OK is enabled, as a screen reader reads it. -fn ok_enabled(h: &Window) -> bool { +pub(super) fn ok_enabled(h: &Window) -> bool { use egui_kittest::kittest::{NodeT, Queryable}; !h.get_by_label("OK").accesskit_node().is_disabled() } /// Clicks the approval's OK as a person does, on a frame at `time` seconds. -fn click_ok(h: &mut Window, time: f64) { +pub(super) fn click_ok(h: &mut Window, time: f64) { use egui_kittest::kittest::Queryable; h.get_by_label("OK").click(); at(h, time); } /// The approval is open and plug-in `id` neither installed nor granted. -fn waiting(app: &VectorcraftApp, id: &str) -> bool { +pub(super) fn waiting(app: &VectorcraftApp, id: &str) -> bool { app.ui.dialog.as_ref().is_some_and(|d| d.kind == dialogs::plugin::INSTALL_KIND) && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none() @@ -184,7 +184,7 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { /// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with /// a bidirectional override, a tab and a bell in it. -fn asking(id: &str, name: &str, permissions: &str) -> Vec { +pub(super) fn asking(id: &str, name: &str, permissions: &str) -> Vec { let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string(); let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}')); wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() @@ -522,9 +522,8 @@ fn the_install_approval_waits_again_when_the_focus_leaves_and_comes_back_between h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); } -/// A module with other bytes than the one installed with its id asks before taking its place: in -/// the ordinary confirm when it needs nothing that only a person can approve, and in the approval -/// dialog, with its id and file hash, when it also asks for a heavy budget. +/// A module with other bytes than the one installed with its id asks before taking its place, in +/// the approval dialog, with its id and file hash, also when it asks for a heavy budget. #[test] fn installing_a_plugin_over_another_module_asks_first() { let mut app = app(); @@ -538,10 +537,13 @@ fn installing_a_plugin_over_another_module_asks_first() { let other = plain(r#"{"objects":[],"error":null}"#); let other_hash = vectorcraft_plugins::permissions::sha256_hex(&other); let r = app.run("ui.installPlugin", module(&other)).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), "Install “Plain” in place of the plug-in with the same ID?"); - assert_eq!(d.str("detail"), format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + assert_eq!(r, json!({"pending": dialogs::plugin::INSTALL_KIND})); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?"); + assert_eq!(pending.detail, format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + assert_eq!(dialogs::confirm(&mut app).unwrap()["pending"], dialogs::plugin::INSTALL_KIND, "it waits like any other question"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(other_hash)); diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index dbdb0283fd..715610f08e 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -117,7 +117,8 @@ fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_request send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]})); assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes"); } - send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]})); + // Null `mods` keep the default, as an omitted value does. + send(&mut app, &ctx, "ui.pointer", json!({"mods": null, "events": [{"kind": "up", "x": x, "y": y, "mods": null}]})); assert_eq!(app.view().unwrap().rotation, 45.0); } diff --git a/crates/ui-egui/src/troubleshooting.rs b/crates/ui-egui/src/troubleshooting.rs new file mode 100644 index 0000000000..5624a7996d --- /dev/null +++ b/crates/ui-egui/src/troubleshooting.rs @@ -0,0 +1,72 @@ +//! Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`): puts the text the host +//! gives ([`crate::Services::troubleshooting`]) on the clipboard for a bug report. The desktop app +//! gives the platform, the graphics processor, recent commands and frame times, and the hang reports +//! of this session and the one before (its `hang_watch` module); without the service the text is +//! the version only. + +use serde_json::{Value, json}; + +use crate::VectorcraftApp; + +/// The status message after the text was put on the clipboard. +pub const COPIED: &str = "Copied the troubleshooting info"; + +/// The status message at launch when the last session stopped responding and was closed before it +/// answered again. +pub const LAST_SESSION_STOPPED: &str = + "The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report"; + +/// The text the command copies. +pub fn text(app: &VectorcraftApp) -> String { + match &app.services.troubleshooting { + Some(text) => text(), + None => format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")), + } +} + +/// `help.copyTroubleshootingInfo`: put [`text`] on the clipboard. → `{chars}`, the text's length in +/// characters. +pub fn copy(app: &mut VectorcraftApp) -> Result { + let text = text(app); + let chars = text.chars().count(); + app.clipboard_out = Some(text); + app.status(COPIED); + Ok(json!({ "chars": chars })) +} + +/// Tell the user, at launch, that the last session stopped responding and was closed. +pub fn last_session_stopped(app: &mut VectorcraftApp) { + app.status(LAST_SESSION_STOPPED); +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + + #[test] + fn the_command_copies_the_hosts_text_and_says_so() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.troubleshooting = Some(Box::new(|| "platform: test\n".to_string())); + let r = app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(r["chars"], 15); + assert_eq!(app.clipboard_out.as_deref(), Some("platform: test\n")); + assert_eq!(app.ui.status, COPIED); + } + + #[test] + fn without_the_service_the_version_is_copied() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(app.clipboard_out, Some(format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")))); + } + + #[test] + fn running_a_command_marks_it_for_the_hang_report() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.beacon = Box::leak(Box::new(crate::hang_beacon::Beacon::new())); + app.run("help.copyTroubleshootingInfo", json!({})).unwrap(); + assert_eq!(app.beacon.recent_commands(), Some(vec!["help.copyTroubleshootingInfo".to_string()])); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fea39a4392..e8c9393deb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -766,6 +766,68 @@ mod gradient_chip_tests { assert_eq!(colors[0], Color32::BLACK); } + /// A stop at opacity 0 is clear on the chip of every kind, and one at 50% half clear. + #[test] + fn chip_shows_the_stops_opacity() { + use vectorcraft_color::{Color, GradientStop}; + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic] { + let mut gp = paint(kind, 0.0); + gp.gradient.stops = vec![ + GradientStop { opacity: 0.5, ..GradientStop::new(0.0, Color::BLACK) }, + GradientStop { opacity: 0.0, ..GradientStop::new(1.0, Color::BLACK) }, + ]; + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + let (lo, hi) = chip.iter().fold((u8::MAX, 0), |(lo, hi), c| (lo.min(c.a()), hi.max(c.a()))); + // No grid point of a conic chip lands exactly at the end of its sweep. + assert!(lo <= if kind == GradientKind::Conic { 12 } else { 0 }, "{kind:?}: {chip:?}"); + assert!(hi.abs_diff(128) <= 1, "{kind:?}: {chip:?}"); + } + let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i]; + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.stops[1].opacity = 0.0; + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + assert_eq!(at(&chip, 0, 2), Color32::WHITE); + assert_eq!(at(&chip, 4, 2).a(), 0); + // Radial: an opaque edge around a clear center. + let mut gp = paint(GradientKind::Radial, 0.0); + gp.gradient.stops[0].opacity = 0.0; + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + assert_eq!(at(&chip, 2, 2).a(), 0); + assert_eq!(chip[0], Color32::BLACK); + } + + /// A chip with a clear stop draws a checkerboard under the gradient; an opaque one does not. + #[test] + fn a_clear_gradient_chip_has_a_checkerboard_under_it() { + let ctx = egui::Context::default(); + let shapes = |gp: &GradientPaint| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| gradient_chip(ui, Rect::from_min_size(pos2(10.0, 10.0), vec2(20.0, 20.0)), gp)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|s| s.shape).collect::>() + }; + let opaque = shapes(&paint(GradientKind::Linear, 0.0)); + assert!(matches!(opaque[..], [egui::Shape::Mesh(_)]), "{opaque:?}"); + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.stops[1].opacity = 0.0; + let clear = shapes(&gp); + let n = clear.len(); + assert!(n > 2 && matches!(clear[n - 1], egui::Shape::Mesh(_)), "{clear:?}"); + let fills: std::collections::HashSet<_> = + clear[..n - 1].iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some(r.fill) } else { None }).collect(); + assert_eq!(fills, [Color32::WHITE, Color32::from_gray(204)].into_iter().collect(), "{clear:?}"); + } + + #[test] + fn freeform_chip_shows_the_points_opacity() { + use vectorcraft_color::{Color, Freeform, FreeformPoint}; + use vectorcraft_geom::kurbo::Point; + let mut gp = paint(GradientKind::Freeform, 0.0); + let clear = FreeformPoint { opacity: 0.0, ..FreeformPoint::new(Point::new(10.0, 10.0), Color::BLACK) }; + gp.freeform = Some(Freeform { points: vec![clear], ..Default::default() }); + let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4); + assert!(chip.iter().all(|c| c.a() == 0), "{chip:?}"); + } + #[test] fn freeform_chip_mixes_points_in_the_model_they_share() { use vectorcraft_color::{Color, Freeform, FreeformPoint}; diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index dca209ce04..017ba588ce 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -39,6 +39,10 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); + // Stops below full opacity show a checkerboard through them. + if colors.iter().any(|c| c.a() < u8::MAX) { + checkerboard(ui, rect); + } let mut mesh = egui::Mesh::default(); for (k, color) in colors.into_iter().enumerate() { let (i, j) = (k % (cols + 1), k / (cols + 1)); @@ -55,14 +59,32 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient ui.painter().add(egui::Shape::mesh(mesh)); } +/// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side +/// (3 to 8 points). +fn checkerboard(ui: &Ui, rect: Rect) { + let p = ui.painter(); + p.rect_filled(rect, 0.0, Color32::WHITE); + let cell = (rect.width().min(rect.height()) / 4.0).clamp(3.0, 8.0); + let cols = (rect.width() / cell).ceil() as usize; + let rows = (rect.height() / cell).ceil() as usize; + for j in 0..rows { + for i in (j % 2..cols).step_by(2) { + let min = pos2(rect.left() + cell * i as f32, rect.top() + cell * j as f32); + let square = Rect::from_min_size(min, Vec2::splat(cell)).intersect(rect); + p.rect_filled(square, 0.0, Color32::from_gray(204)); + } + } +} + /// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` -/// chip showing `gp` as it would look on an object of that size. +/// chip showing `gp` as it would look on an object of that size, with the gradient's opacity as +/// their alpha. pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { use vectorcraft_color::{GradientGeom, GradientKind}; let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); - let rgb = |c: vectorcraft_color::Color| { - let [r, g, b, _] = c.to_rgba8(1.0); - Color32::from_rgb(r, g, b) + let rgba = |(c, a): (vectorcraft_color::Color, f32)| { + let [r, g, b, a] = c.to_rgba8(if a.is_finite() { a } else { 1.0 }); + Color32::from_rgba_unmultiplied(r, g, b, a) }; let at = |k: usize| { let (i, j) = (k % (cols + 1), k / (cols + 1)); @@ -74,9 +96,9 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: let field = gp.freeform_on(painted).model_field(vectorcraft_color::freeform::spread_scale(painted)); return (0..n) .map(|k| { - let ([r, g, b], _) = field.display_sample(at(k)); + let ([r, g, b], a) = field.display_sample(at(k)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(q(r), q(g), q(b)) + Color32::from_rgba_unmultiplied(q(r), q(g), q(b), q(if a.is_finite() { a } else { 1.0 })) }) .collect(); } @@ -90,7 +112,7 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: (0..n) .map(|k| { let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; - rgb(gp.gradient.sample(t).0) + rgba(gp.gradient.sample(t)) }) .collect() } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dbaf2b48ab..640f1e805a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -217,7 +217,9 @@ export's own parameters); `ui.dialog.confirm` writes the file. An importer plug- `pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open` (`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without a path it asks for one); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm -(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md). +(`ui.dialog.confirm` and injected input are refused). When a control request opened it, directly or through a file +dialog, the reply adds `approval: "personMustStartInstall"` and nobody can confirm it: the person chooses Object › +Plug-ins › Install Plug-in… to install the plug-in; see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, @@ -711,8 +713,9 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are `document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, -`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, -`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `includeImages` (Include +Placed Images), `preserve` (`appearance`, `editability`), `selectedOnly`, `alterPaths`, `outlineText`, `includeHatches` +(Include Fill Hatches), plus `path` and the artboard choice. Setting `dxfPreset` to `cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and @@ -794,7 +797,11 @@ artboards' files. A `.ai` file's `aiVersion` comes first: `"pdf"` (the default) Options"), which takes only `transparentImages` (`"flatten"` or `"omit"`), `hiddenObjects` (`"layer"`, `"omit"` or `"visible"`, the Hidden Objects menu), `outlineText` (the Text menu: false Editable, true Outlines) and, with several artboards, the artboard picker: `boards`, one bool per artboard (default: those last saved, else all), saved as `range`, one file per checked -artboard. OK is disabled with none checked. +artboard. OK is disabled with none checked. Save As to an EPS file opens the same dialog after the save panel (heading +"EPS Options") with `useArtboards`, `all` and `range`, then the options Export's EPS Options shows. Preview +(`previewFormat`), Overprints (`overprints`), Flattener preset (`flattenerPreset`) and PostScript (`level`) are menus: +a pick writes the option's value (`"tiffColor"`, `"preserve"`, a built-in preset's id such as `"medium"` or a saved +preset's name, the level as a number), and a value none of them names shows as it is. EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and @@ -1059,7 +1066,7 @@ app: | `vectorcraft.docInspect(params)` / `vectorcraft.uiInspect(params)` | `document.inspect` / `ui.inspect` | | `vectorcraft.inspect()` | `ui.inspect` (the name before `uiInspect`) | | `vectorcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline, as `data` and as `pngBase64`) | -| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens (returns `true` once queued) | +| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens, as automation: a plug-in install it asks for has to be started by the person (returns `true` once queued) | | `vectorcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded | | `vectorcraft.files()` / `vectorcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` | diff --git a/docs/development.md b/docs/development.md index a0960d3cbd..2362fc6f1a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,6 +89,8 @@ The desktop app writes its `log` records to standard error and to `logs/vectorcr By default VectorCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,vectorcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`vectorcraft*=debug`). The logger is `apps/vectorcraft/src/logging.rs`. `vectorcraft-cli mcp` has its own logger, which sends records to the MCP client (`docs/mcp.md`). +When the interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-.txt`, which the process keeps locked. The report gives the part of the frame (the input hook, the app's logic, the interface, or the renderer after a frame in which any window had input), a system clipboard read or write in progress, the last 8 different command ids (a command run again moves to the end of the list), the times of the last 16 frames, the platform, the window system (Wayland, X11, AppKit, Win32, other or unknown), `XDG_CURRENT_DESKTOP`, `XDG_SESSION_TYPE`, whether the app runs in a Flatpak, the graphics adapter, the present mode and frame latency the app asks the window surface for, the session's running time and how many documents are open. It holds no document contents, file paths or command parameters, and no native stack. The time after a frame that minimizes or hides the main window, or starts moving or resizing it, is not timed, and neither is an in-line file dialog (Windows and Linux) or an open menu of the macOS menu bar. When the interface answers again, the log records after how long and the file is removed. A file that no process holds locked at a later launch was left by a session that was closed while it did not answer (a locked one belongs to another instance and is left alone): that launch writes the report to the log, moves the file to `logs/hang-report.1.txt` (older abandoned reports beyond the newest 16 are removed) and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the hang reports of this session and the last one, for a bug report. The watch is `apps/vectorcraft/src/hang_watch.rs`; the marks it reads are `crates/ui-egui/src/hang_beacon.rs`. + ## Environment variables (desktop app) | Variable | Effect | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa682c0272..23a36a7e9a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -290,7 +290,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | -| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | +| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | @@ -488,25 +488,39 @@ Stop opacity on translated artboards remains outside this regression coverage. Feature evidence updated 2026-10-26; scores and estimates unchanged. -EPS is offered by Save As, and an opened EPS keeps its own path for ordinary Save. Save writes -one artboard (the first by default, or the named artboard), retaining its page bounds and transparent -margins rather than cropping to the drawn artwork. The whole editable VectorCraft document remains -embedded in EPS comments, including other artboards. Save bounds the optional TIFF preview to +EPS is offered by Save As, and an opened EPS keeps its own path for ordinary Save. The EPS +Options of Save As show Use Artboards with All or Range under it, for a separate file per artboard +([saving in EPS](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/save-artwork-in-eps-format.html)), +unchecked by default (the scripting reference's EPS save options list `saveMultipleArtboards` with +a default of false, +[scripting reference](https://web.archive.org/web/20160803105056/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20JavaScript%20Scripting%20Reference.pdf)). +Unchecked, Save writes one file whose bounding box fits all the visible art, as Export does, +without the artboards' background colors; an opened EPS saved back this way loses the margins its +page had. With no visible art the page is the first artboard, in its color. Naming artboards with +`artboard`, `artboards` or `range` turns Use Artboards on. Checked with two or more chosen, the +file at the path is the central file of the visible art and each artboard is also written to +`<name>_<artboard>.eps` beside it, holding that artboard. Checked with one chosen, VectorCraft +writes that artboard as the page of the file at the path alone, retaining its bounds and +transparent margins. The file the document becomes carries the whole editable VectorCraft document +in EPS comments, including other artboards. Save bounds the optional TIFF preview to 1024 pixels on its longest side (about 4 MiB of RGBA pixels), avoiding the roughly 16 MiB preview a large artboard otherwise produces. Export keeps its existing 2048-pixel preview cap. Preview format choices are unchanged; no public preview-resolution option exists. The EPS PostScript appearance still flattens transparency; its existing warnings remain. This does not promise native Illustrator editing-data fidelity. -Cropped and selected-only EPS output remains available through Export. Save refuses those options. +Selected-only EPS output remains available through Export. Save refuses `selectedOnly`. An EPS opened from a preview, a partially interpreted program or damaged native editing data keeps its source-loss warning: writing over that original requires explicit confirmation. File › Save uses the same engine path and write-failure handling as other document formats. -Synthetic regressions: `tests::save::opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins`, -`eps_save_keeps_all_editing_artboards_while_printing_one_page`, +Synthetic regressions: `tests::save::opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page`, +`eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard`, +`eps_save_keeps_all_editing_artboards_in_the_file_of_one_page`, +`eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard`, +`cmd::fileio::tests_artboard_fill::eps_save_draws_the_color_on_an_artboard_page`, `eps_save_supports_an_empty_artboard_without_cropping_its_page`, `partially_read_eps_requires_confirmation_before_save_back`, `eps_with_damaged_editing_data_requires_confirmation_before_save_back`, `eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data`, and the UI regressions `file_save_writes_opened_eps_back_without_another_save_as_dialog` and -`eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches`. +`eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard`. diff --git a/docs/gaps.md b/docs/gaps.md index 1ce258a111..4186fa64dd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -199,7 +199,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. -- **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. +- **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. Since 2026-11-12 Save keeps the page margins only with Use Artboards checked; unchecked, the default, the bounding box fits the visible art. - **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. @@ -323,8 +323,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). - **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1). @@ -1097,4 +1096,4 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv 2026-10-28 behavior correction: Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. diff --git a/docs/mcp.md b/docs/mcp.md index e656c8d240..6bcf5054f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -153,6 +153,11 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots + (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as + `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and + `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the + read roots, and the preset stays. The preferences themselves are not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice @@ -509,7 +514,7 @@ applied colors still join the list while it is hidden. | `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`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | -| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -1336,6 +1341,22 @@ fails with no change. A description with a `…` entry takes more keys than it l object can carry several formats' options). A command that takes its own reply back (`document.setup`, `text.areaOptions`) says so the same way. +The app's own commands are not checked against their descriptions, but those listed here refuse a value of the wrong +type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (`mode`), `window.panel` (`toggle`, +`toggleGroup`), `file.revert` (`confirmed`), `file.close` (`index`), `ui.newSwatch` (`spot`, `global`), +`ui.symbolOptions` (`edit`), `ui.batchDialog` (`set`, `action`) and `file.exportSelection` (`multiple`). So do the +control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`, +`ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot +calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s +`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer event's `pressure`, +`holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers and its `space` must be `doc` or `screen`. A pointer packet +with one malformed event sends none of its events: + +```json +{"id": "view.setZoom", "params": {"zoom": "200"}} +→ `zoom` must be a number +``` + Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function key, the Batch dialog or `ui.action.play`), a key a command no longer reads, recorded by an older version, is ignored and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect @@ -1776,7 +1797,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: | `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | | `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | | `screens` | an empty array | -| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait, and so does `notifications/cancelled` for a run its `tools/call` waits for, without waiting for the time to pass; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | | `strict` | `false` | Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can @@ -1862,7 +1883,7 @@ VectorCraft sets these limits itself: | Included and evaluated files | 64 in all, 16 levels deep, 1,024 paths looked up | | Heap | 256 MiB (128 MiB on the web) | | Host calls | 2,000,000 | -| `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | +| `maxFuel`, instructions in all, plus one for each UTF-16 code unit of source that `eval`, `Function` or an evaluated file compiles during the run | 1 to 100,000,000,000; 1,000,000,000 by default | | Instructions without a pause | 200,000,000 | | Nesting depth | 64 levels | | A value crossing to or from a command | 16 MiB and 1,048,576 entries | @@ -5925,8 +5946,11 @@ DXF layer (hidden layers switched off, locked ones locked, non-printing ones not straight paths become polylines (an open path of one straight segment becomes a line, except for a stroke weight before 2000), curved ones cubic splines through every anchor, fills their outlines followed by solid hatches (laser and cutter software reads the outlines and skips hatches; R12 has no hatches: the outlines -alone), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked -to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). Coordinates are y up +alone; an open path's fill outline is closed, as the fill is), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked +to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). `includeImages: false` +leaves placed images out, with no image entity, no linked file and no decoding of their pixels, and `includeHatches: +false` writes each filled area as its outlines alone, without its solid hatch (both default to `true`; these two +options are VectorCraft's own). Either one, when it leaves something out, adds one note to `warnings`. Coordinates are y up from the bottom-left corner of the first artboard (or `artboard`), in drawing units: `scale` units per `unit` (default 1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or `256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve: @@ -5939,7 +5963,8 @@ selected objects in their layers (every export takes it), and `useArtboards: tru artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: "mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every -stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +stroke a center line), `outlineText: true` (type as paths), `includeImages: false` and `includeHatches: false` (the +cutting paths alone: fills as their outlines, no image files), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, which shows Custom again once a field it sets is changed. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such @@ -6992,7 +7017,9 @@ the middle of its bounding box's right side, hollow on point type and filled on Type and Convert To Point Type. Either keeps the text and its styles and is one undo step; `ids` picks the objects (default: the selection). A frame of threaded text that `type.convertToPointType` converts leaves its thread with the text it holds, and the story flows through the other frames without that text; a thread left with one frame is no -longer a thread. +longer a thread. Area type with Dynamic Text (Block) on becomes point type with Dynamic Text off, at the sizes Block +set its lines to, as `type.dynamicText {on: false}` leaves them; converting it back to area type does not turn Block +on again. ```json {"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}} @@ -8009,7 +8036,10 @@ needs a person's approval too, and `plugin.install` refuses `grant: [permission] app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`, `ui.dialog.confirm` and injected input can't confirm it. Its OK takes input only once the question has been on screen in the focused window for half a second, counted again for every new question and after the window gets the focus -back. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` +back. A question that a request asked, directly or through a file dialog it opened, can't be approved by the person +either: the reply adds `approval: "personMustStartInstall"`, OK stays disabled, and the person installs the plug-in +with Object › Plug-ins › Install Plug-in… themselves. A request's `replace: true` doesn't skip the question about +taking another module's place. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with `--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install @@ -8553,7 +8583,8 @@ keys without bounds make 1 step. `starts` gives, per object, the anchor of its f from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). With the Blend tool, `press_key` Escape drops the object clicked first and stops the next click adding to the blend just made; the document and the selection stay as they are. A blend -among the objects takes the others in as more key objects, keeping its options, name and transparency: +among the objects takes the others in as more key objects, keeping its options, name, transparency and whether it +has a spine: ```json {"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[12,15],"starts":[0,2],"steps":8}}} @@ -8561,6 +8592,23 @@ among the objects takes the others in as more key objects, keeping its options, {"name":"run_command","arguments":{"command":"object.blend.info","params":{}}} ``` +`object.blend.createSpine {mode?: "auto" | "always" | "never"}` is the Blend panel's Create Spine menu: whether new +blends get a spine (the [help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) +describes the three choices). Without `mode` it reads the setting; both return `{mode}`. Auto, the default, and Always +give every new blend a straight spine between the key centers, as before, whatever its key objects are. Never makes blends without a spine: their steps sit where a straight spine puts them, and +`object.blend.info` reports `hasSpine: false` and no `spine`. Such a blend has no easing (`easingType` and `ramp` are +refused, and the Blend panel dims Type and Ramp), Reverse Spine and the `object.blend.spine.*` commands refuse it +("the blend has no spine"), and Release gives back its keys with no spine path. Replace Spine gives it a spine. The +setting is kept with the preferences, is not an undo step and does not change blends already made, and +`object.blend.make {createSpine}` chooses for one new blend. Any other value is refused ("`mode` must be one of auto, +always, never") and changes nothing. A blend saved without a spine keeps `noSpine` in the native file; `.ai` and +EPS files hold its drawn steps, as they do for every blend: + +```json +{"name":"run_command","arguments":{"command":"object.blend.createSpine","params":{"mode":"never"}}} +{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[20,31],"createSpine":"always"}}} +``` + With the Blend tool active, `tool.select {tool: "blend"}` finishes the blend, as a click on the tool's button or W does: it drops the object clicked first and stops the next click adding to the blend just made. The document and the selection stay as they are. Choosing any other tool that is already active leaves its work as it is. @@ -8571,7 +8619,7 @@ the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a `handle: "in" | "out"` it places that handle), `object.blend.spine.convertAnchor {anchor, to: "smooth" | "corner", x?, y?}` converts a point's handles (a Smooth drag with `x, y` creates opposite handles; Corner retracts both), `object.blend.spine.reshapeSegment {segment, t, dx, dy, perpendicular?}` bends a segment so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`perpendicular` works as in `path.reshapeSegment`), and `object.blend.spine.removeAnchor {anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release -(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result): +(`object.blend.release`) leaves the spine of a blend that has one as a path that paints nothing (`spines` in the result): ```json {"name":"run_command","arguments":{"command":"object.blend.spine.addAnchor","params":{"id":40,"x":150,"y":100}}} diff --git a/docs/plugins.md b/docs/plugins.md index dacf59b218..d3af5fa06c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -217,7 +217,16 @@ capability model proposed for PhotoCraft plug-ins https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel - and Escape work at once. + and Escape work at once. A module that would take the place of the installed plug-in with its id asks in the same + dialog. +- **An install that automation asked for is not approved in its dialog.** When a control request, MCP, injected input, + or a file dialog that one of them opened (even when the person picks the file there) leads to the approval dialog, its + OK stays disabled, a confirm is refused even after the half second, and the dialog says that automation asked for the + install. `ui.installPlugin` returns `{pending: "pluginInstall", approval: "personMustStartInstall"}`. Cancel still + closes it. To install the plug-in, the person chooses Object › Plug-ins › Install Plug-in… and picks the file, or + opens it with File › Open or drops it on the window: the module is inspected again and the new question waits its + half second. A request's `replace: true` does not skip asking about taking another module's place. A module that + needs no approval still installs at once from any request. This is VectorCraft's own rule. - `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant (`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants` @@ -262,14 +271,16 @@ capability model proposed for PhotoCraft plug-ins write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be removed and the module already stored can't be read back or written back, the new module stays in the folder, and - the grant does not count for the rest of the session. The grant also does not count when the folder held no module - for that id. A + the grant stops counting. The grant also stops counting when the folder held no module for that id. A `grants.json` larger than 1 MiB, or one that is not a JSON list, grants nothing: the new module stays in the folder, and `plugin.remove` removes the plug-in. A `grants.json` that can't be opened or read counts as a grant that can't be removed. When VectorCraft loads the `Plug-ins` folder, it forgets any grant for an id whose stored module lists no - permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant does not count for - the rest of the session, or until the plug-in is granted again, including for the next load, which tries the removal - again. In Safe Mode, and for + permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant stops counting, + including for the next load, which tries the removal again. A grant that stops counting in either of these cases + does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until + `grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same + folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the + same id kept in another `Plug-ins` folder counts. In Safe Mode, and for a stored module that the user disabled, VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an diff --git a/docs/roadmap.md b/docs/roadmap.md index 41066cbb49..cf782106a1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: 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). > @@ -151,7 +151,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, 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 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 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 | @@ -380,4 +381,4 @@ separate. Scores and hours unchanged. [Behavior and command details](development 2026-10-28 behavior correction: Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7108f4d676..3e718c60df 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -216,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de |---|---:|---:|---|---:|---| | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects with bounds-fitted gradients kept in place (#3380, deletion only; `crates/engine/tests/it/delete_path_pieces.rs`); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | @@ -236,7 +238,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | @@ -617,6 +619,8 @@ since 2026-10-01. - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. +- **EPS Save with Use Artboards:** Save As › EPS Options show Use Artboards with All or Range, for a separate file per artboard ([saving in EPS](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/save-artwork-in-eps-format.html)), unchecked by default (the scripting reference's EPS save options list `saveMultipleArtboards` with a default of false, [scripting reference](https://web.archive.org/web/20160803105056/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20JavaScript%20Scripting%20Reference.pdf)). Unchecked, Save writes one file whose bounding box fits all the visible art, as Export does, without the artboards' background colors; when no art shows, the page is the first artboard in its color. An opened EPS saved back this way loses its page margins. Checked with two or more chosen artboards, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it. Checked with one chosen artboard, VectorCraft writes that artboard as the page of the file at the path alone (the behavior Save had since #4293). Evidence: `cmd::fileio::tests_artboard_fill::eps_save_draws_the_color_on_an_artboard_page`, `tests::save::eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard`, `tests::save::opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page`, `tests::save::eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard`, `dialogs::save_options::tests::eps_save_as_shows_use_artboards_off_and_writes_the_art_or_the_artboard`. + - **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). @@ -743,6 +747,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. - Scripts: `toSource` throws a `TypeError` for a property that is not writable or not configurable, a sealed, frozen or non-extensible object or array, and an array with a read-only `length` (VectorCraft's own rule). `crates/js/src/extension_tests.rs`. + - Scripts: a canceled request stops a run that sleeps in `$.sleep`, waits at a dialog or waits for approval to grow at the run's next slice, before its wake time or an answer, and every document goes back to its state before the run. VectorCraft's own rule. `crates/engine/src/tests/script_dollar.rs`, `crates/engine/src/tests/script_dialogs.rs`, `crates/engine/src/tests/script_run.rs`. - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. @@ -756,6 +761,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). @@ -768,6 +774,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `<!DOCTYPE>` in an XML string gives a `SyntaxError`. An XML declaration at the start of an XML string, after optional white space, is checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of `1.` and digits, then an optional encoding name and an optional standalone `yes` or `no`, in that order; any other declaration, or one with a character XML does not allow, gives a `SyntaxError`. The parser takes fuel for the length of every name, white space run and declaration it scans, and for the text it searched when a quote, CDATA section, comment or processing instruction is not closed. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. @@ -1181,7 +1188,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: reshaping or scaling the path fits the text again; one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). - Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. @@ -1276,6 +1283,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. + The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. @@ -1283,7 +1291,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. @@ -1508,7 +1516,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes. An open path's fill outline is closed, as the fill is, in every version (`an_open_path_s_fill_outline_keeps_its_closing_edge`, `fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. @@ -1686,6 +1695,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. +- **Hang report and Copy Troubleshooting Info:** when the desktop app's interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-<process id>.txt`, and removes the file when the interface answers again. A later launch that finds a report no process holds writes it to the log, keeps it as `logs/hang-report.1.txt` and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the reports of this session and the last one. Regressions: `apps/vectorcraft/src/hang_watch/tests.rs`, `crates/ui-egui/src/hang_beacon.rs`, `crates/ui-egui/src/troubleshooting.rs`. Scores and hours unchanged. - **A disabled plug-in whose update stays in the store is turned on:** when `plugin.install` writes a module that lists permissions to the `Plug-ins` folder, its grant can't be written, and the folder can't be returned to the old module (or, on a first install, the new module can't be removed), the folder holds the new module, so a disabled plug-in is turned on, by id and by stored file, as for any install the folder holds. The old grant, or none, doesn't cover the new module, so the next start skips it until it is approved. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. @@ -1695,7 +1705,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. +- **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged. @@ -2024,10 +2036,22 @@ With area type selected, the Properties panel's Paragraph section and the Contro Edit › Copy and Cut put the selected ruler guides on the clipboard, with the selected objects or on their own, so they paste into another artboard or document (#3226). Pasted guides go on the layer new art goes to and are selected; with objects they move as the objects do, and alone they keep their places, while Paste in Place, in Front and in Back put them on the active artboard as they lay on theirs and Paste on All Artboards on every artboard. Guides have no format other apps read, so a Copy of guides alone empties the system clipboard, and the next Paste takes the guides rather than what another app copied before. Tests: `tests::guide_clipboard`, `tests::sysclip_guides` (ui-egui). +- **Live Paint Bucket drag (#6373):** starting on a face, a drag fills every visible face it + crosses in that group, including faces between pointer events. The existing region-crossing + calculation is reused; the gesture is one Undo step and Escape cancels it. Selected paths are + made live by the first press as before. Regression evidence: + `live_paint_bucket_drag_fills_multiple_faces` and + `live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel` cover sparse pointer events, + a 0.25 pt grid cell, making selected paths live, deferred drawing, cancellation and Undo; + `live_paint_bucket_drag_rejects_invalid_points_atomically` covers invalid command input. + Behaviour source: [Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html). + Scores and estimates unchanged. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | @@ -2419,4 +2443,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. \ No newline at end of file diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58e5276573..68ca786226 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -436,7 +436,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | -| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | +| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500) | | macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet | | Preferences: every option acts | partial | 2 stored but unused (#394, audited 2026-10-30): Scratch Disks, Legacy vector clipboard; the rest act (Units › East Asian Type since 2026-10-31; Open Documents As Tabs: cleared, new documents open in floating windows). General › Corner Radius drives the Rounded Rectangle tool and follows its ↑/↓ (2026-10-24) | | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned | @@ -509,10 +509,17 @@ rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/ coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22). +## Live Paint Bucket + +Dragging from a face fills the visible faces crossed in that Live Paint group, even when a +narrow face lies between pointer events. Undo restores the whole drag, and Escape cancels it +before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. |