diff --git a/ASSETS.md b/ASSETS.md index 34d75235c6..5658882d48 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -267,6 +267,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/flip-vertical-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/folder-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/frame.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | +| `assets/icons/graduation-cap.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Learn panel, Explore tutorials button | | `assets/icons/grid-3x3.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/group.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/hand.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | diff --git a/ROADMAP.md b/ROADMAP.md index a9187e8a25..87aaed1109 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). +- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). @@ -138,6 +139,8 @@ 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-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. diff --git a/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/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 5756600281..c5ef4d5579 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -651,6 +651,7 @@ mod portable { impl Answer { /// The answer to `request` when it couldn't be read: `why`, or no files. + #[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn failed(request: Request, why: String) -> Self { match request { Request::Text => Self::Text(Err(why)), diff --git a/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..72d13db08f 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(); @@ -355,7 +378,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Load Actions… refuses a file over its size limit without reading all of it. read_head: Some(Box::new(read_file_head)), write: Some(Box::new(write_file)), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), // Native saves stream into the file as they are encoded. @@ -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/assets/icons/graduation-cap.svg b/assets/icons/graduation-cap.svg new file mode 100644 index 0000000000..fbce25edcc --- /dev/null +++ b/assets/icons/graduation-cap.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 3e262b0764..34f77ec929 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader and writer" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs index 5016e301f6..9494fbee46 100644 --- a/crates/cad/src/hpgl.rs +++ b/crates/cad/src/hpgl.rs @@ -16,11 +16,18 @@ //! and the art of every page goes on one artboard; each comes back as a warning. Printer escape //! sequences and PJL lines around the instructions are skipped. //! +//! [`export`] writes the visible art over a region as an HP-GL or HP-GL/2 plot, and +//! [`export_regions`] one plot per region with one pen numbering: see [`write`]. +//! //! - `lex`: the bytes as instructions, numbers, labels and PE data. //! - `plot`: the instructions played on the pen, as shapes. +//! - `write`: the writer, paths to pen moves. mod lex; mod plot; +pub mod write; + +pub use write::{HpglOptions, HpglOutput, Origin, Version, export, export_regions}; use std::sync::Arc; diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs new file mode 100644 index 0000000000..3e20c69a98 --- /dev/null +++ b/crates/cad/src/hpgl/write.rs @@ -0,0 +1,1134 @@ +//! HP-GL export: the visible art over a region as pen moves, for pen plotters and cutters. +//! +//! Every visible path is drawn once along its own line: its fills and strokes are not painted, so +//! a filled shape is its closed outline (what a cutter cuts) and a stroke is its center line. Each +//! color is a pen: by default pen 1 is the first color the plot draws in, pen 2 the next new one, +//! and so on; with a pen table each path takes the pen whose color is nearest its own. The plots +//! of one export ([`export_regions`]) share the numbering. A path's color is its topmost visible +//! stroke's, else its topmost visible fill's (one at 0% opacity does not count); a gradient is the +//! average of its stops and a pattern its first visible solid fill (gray without one). Curves are +//! flattened to straight moves within a tolerance. Type is drawn as its glyph outlines, a gradient +//! mesh as its outline in the average of its shown points' colors, clipping masks cut what they clip, +//! and the art is cut at the region's edges. Hidden and non-printing layers, hidden objects, +//! objects at 0% opacity, template layers, guides and placed images are left out. +//! +//! Coordinates are plotter units (1016 to the inch, y up) from the region's bottom-left corner or +//! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range +//! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. +//! An HP-GL plot with a coordinate outside −32,768 to 32,767 (16-bit numbers, the range older +//! devices read) gets a warning; HP-GL/2's encoded polylines take the wider range. +//! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with +//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`, +//! a pen draws a path only after every path of its own that lies inside it (a letter's counter +//! before the letter, a hole before the part). +//! +//! HP-GL writes `IN;`, then for each pen `PU;SP;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`. +//! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths +//! as one encoded polyline (`PE`, base 64). + +mod clip; +mod nest; + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; +use std::sync::Arc; + +use kurbo::PathEl; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, SymbolBoxes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect}; + +use crate::MAX_NEST; +use clip::Region; + +/// Plotter units per point. +const PLU_PER_PT: f64 = 1016.0 / 72.0; +/// The largest coordinate written, plotter units. +pub const MAX_COORD: i64 = (1 << 23) - 1; +/// The coordinates an HP-GL plot keeps to without a warning: 16-bit numbers, plotter units. +const CLASSIC_RANGE: std::ops::RangeInclusive = -32_768..=32_767; +/// The curve tolerance a path is flattened to by default, points. +pub const DEFAULT_TOLERANCE: f64 = 0.1; +/// The smallest and largest curve tolerance, points. +pub const TOLERANCE_RANGE: std::ops::RangeInclusive = 0.01..=10.0; +/// The most pens: a pen table's length, and the colors that get a pen of their own. +pub const MAX_PENS: usize = 255; +/// The most points the plots of one export hold. +const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 }; +/// The most work sorting paths and finding which lie inside others may take, over the whole plot: +/// one for each point or box compared. +const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 }; +/// The most edge tests clipping masks may take, over the whole plot. +const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 }; +/// The most points the clipping masks around an object may hold, together. +const MAX_MASK_POINTS: usize = if cfg!(test) { 10_000 } else { 10_000_000 }; +/// The most objects live art (blends, envelopes, repeats) may make for a plot, together, as one +/// evaluation of live art may make ([`vectorcraft_doc::live::MAX_GENERATED`]). +const MAX_GENERATED: u64 = if cfg!(test) { 1_000 } else { vectorcraft_doc::live::MAX_GENERATED as u64 }; + +const TEXT_OUTLINES: &str = "type is plotted as its glyph outlines"; +const INLINE_GRAPHICS: &str = "inline graphics in type are left out"; +const IMAGES: &str = "placed images are left out: a pen draws only lines"; +const MASKS: &str = "opacity masks are left out"; +const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out"; +const STROKE_LOOK: &str = "stroke widths, dashes, brushes and arrowheads are left out: each path is drawn once along its own line"; +const TOO_MANY_POINTS: &str = "the export is too large: the art past its first 10 million points was left out"; +const CLIP_WORK: &str = "clipping masks are too complex to apply in full: the art past that point is drawn whole"; +const MASK_POINTS: &str = "clipping masks past 10 million points together are left out: the art they clip is drawn unclipped by them"; +const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; +const PAST_CLASSIC_RANGE: &str = "the plot has coordinates past 32,767 plotter units (about 82 cm) from the origin, which older HP-GL devices may not read: HP-GL/2 takes a wider range"; +const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; +const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; +const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it"; +const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it"; +const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; +const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out"; +const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out"; + +/// The language written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Version { + /// `IN`, `SP`, `PU` and `PD` only. + #[default] + Hpgl, + /// HP-GL/2: pen colors (`NP`, `PC`) and encoded polylines (`PE`). + Hpgl2, +} + +impl Version { + pub const ALL: [Self; 2] = [Self::Hpgl, Self::Hpgl2]; + + /// The `hpglVersion` param value. + pub fn id(self) -> &'static str { + match self { + Self::Hpgl => "hpgl", + Self::Hpgl2 => "hpgl2", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Hpgl => "HP-GL", + Self::Hpgl2 => "HP-GL/2", + } + } + + /// A version by id (`hpgl`, `hpgl2`; `hp-gl` and `hp-gl/2` also read), any case. + pub fn from_id(s: &str) -> Option { + let s = s.trim(); + Self::ALL.into_iter().find(|v| s.eq_ignore_ascii_case(v.id()) || s.eq_ignore_ascii_case(v.label())) + } +} + +/// Where the plot's origin (0, 0) lies on the region. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Origin { + #[default] + BottomLeft, + Center, +} + +impl Origin { + pub const ALL: [Self; 2] = [Self::BottomLeft, Self::Center]; + + /// The `origin` param value. + pub fn id(self) -> &'static str { + match self { + Self::BottomLeft => "bottomLeft", + Self::Center => "center", + } + } + + /// An origin by id, any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|o| s.trim().eq_ignore_ascii_case(o.id())) + } +} + +/// The HP-GL export settings. +#[derive(Clone, Debug)] +pub struct HpglOptions { + pub version: Version, + /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]; a value + /// outside it is an error). + pub tolerance: f64, + /// The pen table: pen 1's color first, up to [`MAX_PENS`] (more is an error). Empty: a pen for + /// each color the art draws in. + pub pens: Vec<[u8; 3]>, + pub origin: Origin, + /// Draw each pen's paths nearest first, instead of in paint order. + pub sort: bool, + /// Draw a path only after the pen's paths that lie inside it. + pub inside_first: bool, + /// The area plotted (document space): its bottom-left corner or center is the origin, and the + /// art is cut at its edges. + pub region: Rect, +} + +impl Default for HpglOptions { + fn default() -> Self { + Self { + version: Version::default(), + tolerance: DEFAULT_TOLERANCE, + pens: vec![], + origin: Origin::default(), + sort: false, + inside_first: false, + region: Rect::ZERO, + } + } +} + +/// A written plot. +#[derive(Clone, Debug, Default)] +pub struct HpglOutput { + pub bytes: Vec, + /// The pens' colors, pen 1's first. + pub pens: Vec<[u8; 3]>, + /// What was approximated or left out. + pub warnings: Vec, +} + +/// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are +/// applied first. +pub fn export(doc: &Document, opts: &HpglOptions) -> Result { + Ok(export_regions(doc, opts, &[opts.region])?.pop().unwrap_or_default()) +} + +/// Write the visible art of `doc` over each of `regions` as an HP-GL plot, one per region, as +/// [`export`] does with `opts.region`. The plots share one pen numbering, so a color is the same +/// pen in each, and one point budget. +pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> Result, String> { + let regions: Vec = regions.iter().map(|r| r.abs()).collect(); + if regions.iter().any(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err("the export region is empty".into()); + } + if !TOLERANCE_RANGE.contains(&opts.tolerance) { + return Err("the curve tolerance must be from 0.01 to 10 points".into()); + } + if opts.pens.len() > MAX_PENS { + return Err("the pen table holds more than 255 pens".into()); + } + let tolerance = opts.tolerance; + // Layers the plot leaves out go first: baking can wrap one in art that would be drawn (its + // crop marks). + let shown = plotted(&doc.layers, false).map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); + let doc = shown.as_ref().unwrap_or(doc); + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); + // Each plot's lines in plotter units, with the points a move doesn't change dropped, and its + // warnings. + let mut plots: Vec<(Lines, Vec)> = Vec::with_capacity(regions.len()); + let mut points = 0; + for r in regions { + // Document space to plotter units, y up. + let o = match opts.origin { + Origin::BottomLeft => Point::new(r.x0, r.y1), + Origin::Center => r.center(), + }; + let span = MAX_COORD as f64 / PLU_PER_PT; + let range = Rect::new(o.x - span, o.y - span, o.x + span, o.y + span); + let mut walk = Walk::new(doc, r.intersect(range), tolerance); + walk.points = points; + // Only a region past the range can have art the range cuts off. + if r.intersect(range) != r { + walk.range = Some((range, r)); + walk.asked = Region::new(vec![corners(r)], FillRule::NonZero); + } + for l in &doc.layers { + walk.node(l, false); + } + points = walk.points; + let Walk { runs, warnings, .. } = walk; + let to_plu = |p: Point| { + let x = ((p.x - o.x) * PLU_PER_PT).round().clamp(-(MAX_COORD as f64), MAX_COORD as f64) as i64; + let y = ((o.y - p.y) * PLU_PER_PT).round().clamp(-(MAX_COORD as f64), MAX_COORD as f64) as i64; + (x, y) + }; + let lines = runs + .into_iter() + .filter_map(|run| { + let mut pts: Vec<(i64, i64)> = Vec::with_capacity(run.points.len()); + for p in run.points { + let q = to_plu(p); + if pts.last() != Some(&q) { + pts.push(q); + } + } + // A line shorter than a plotter unit draws nothing and takes no pen. + (pts.len() >= 2).then_some((run.rgb, pts)) + }) + .collect(); + plots.push((lines, warnings)); + } + let mut pen_warnings = vec![]; + let colors: Vec<[u8; 3]> = plots.iter().flat_map(|(lines, _)| lines.iter().map(|(c, _)| *c)).collect(); + let (pens, of_line) = assign_pens(&colors, &opts.pens, &mut pen_warnings); + let mut of_line = of_line.into_iter(); + let mut out = Vec::with_capacity(plots.len()); + for (lines, mut warnings) in plots { + let mut by_pen: Vec>> = vec![vec![]; pens.len()]; + for ((_, pts), pen) in lines.into_iter().zip(of_line.by_ref()) { + if let Some(paths) = by_pen.get_mut(pen) { + paths.push(pts); + } + } + if !by_pen.iter().all(Vec::is_empty) { + pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); + } + if opts.sort || opts.inside_first { + let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); + for paths in &mut by_pen { + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + } + } + if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { + warn(&mut warnings, PAST_CLASSIC_RANGE); + } + let bytes = match opts.version { + Version::Hpgl => plain(&by_pen), + Version::Hpgl2 => encoded(&by_pen, &pens), + }; + out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + } + Ok(out) +} + +/// A plot's lines in plotter units, each with its color. +type Lines = Vec<([u8; 3], Vec<(i64, i64)>)>; + +fn warn(warnings: &mut Vec, w: &str) { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } +} + +/// The corners of `r`, as a clipping ring. +fn corners(r: Rect) -> Vec { + vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] +} + +/// Does `r` hold `p`, its edges included? +fn holds(r: Rect, p: Point) -> bool { + p.x >= r.x0 && p.x <= r.x1 && p.y >= r.y0 && p.y <= r.y1 +} + +/// The pens and each line's pen (0-based), from the lines' `colors`: with a table, the table and +/// the nearest of its colors; else each new color a pen of its own, up to [`MAX_PENS`], and the +/// nearest after that. +fn assign_pens(colors: &[[u8; 3]], table: &[[u8; 3]], warnings: &mut Vec) -> (Vec<[u8; 3]>, Vec) { + let mut pens: Vec<[u8; 3]> = table.iter().take(MAX_PENS).copied().collect(); + let fixed = !pens.is_empty(); + let mut of_line = Vec::with_capacity(colors.len()); + for &rgb in colors { + let pen = match pens.iter().position(|c| *c == rgb) { + Some(i) => i, + None if !fixed && pens.len() < MAX_PENS => { + pens.push(rgb); + pens.len() - 1 + } + None => { + if !fixed { + warn(warnings, MANY_COLORS); + } + nearest(&pens, rgb) + } + }; + of_line.push(pen); + } + (pens, of_line) +} + +/// The index of the color in `pens` nearest `c` (the first of equally near ones). +fn nearest(pens: &[[u8; 3]], c: [u8; 3]) -> usize { + let d = |p: &[u8; 3]| (0..3).map(|i| (i32::from(p[i]) - i32::from(c[i])).pow(2)).sum::(); + pens.iter().enumerate().min_by_key(|(_, p)| d(p)).map_or(0, |(i, _)| i) +} + +fn dist2(a: (i64, i64), b: (i64, i64)) -> i128 { + let (dx, dy) = (i128::from(a.0) - i128::from(b.0), i128::from(a.1) - i128::from(b.1)); + dx * dx + dy * dy +} + +/// The squared distance from `p` to box `b` (`[x0, y0, x1, y1]`), 0 inside it. +fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { + let d = |v: i64, lo: i64, hi: i64| i128::from(lo.saturating_sub(v).max(v.saturating_sub(hi)).max(0)); + let (dx, dy) = (d(p.0, b[0], b[2]), d(p.1, b[1], b[3])); + dx * dx + dy * dy +} + +/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest +/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or +/// for a closed path any of its points (where it then starts); without, the first in paint order. +/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. +/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with +/// `inside_first`, with a warning. Returns where the pen stops. +fn reorder( + paths: &mut Vec>, + mut at: (i64, i64), + work: &mut u64, + nearest: bool, + inside_first: bool, + warnings: &mut Vec, +) -> (i64, i64) { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); + // The paths each path lies inside, and how many paths inside each are still to draw. + let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); + if inside_first && outer.is_none() { + warn(warnings, UNNESTED); + } + let mut waiting = vec![0usize; paths.len()]; + for o in outer.iter().flatten().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w += 1; + } + } + if !nearest { + if let Some(outer) = &outer { + let all = std::mem::take(paths).into_iter().enumerate().collect(); + inner_first(all, outer, &mut waiting, paths); + } + return at; + } + // Each path with its index and box. + let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect(); + while !left.is_empty() { + // (index, reversed, the closed path's point to start at, distance) + let mut best: (usize, bool, Option, i128) = (usize::MAX, false, None, i128::MAX); + for (i, (j, p, b)) in left.iter().enumerate() { + let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; + *work = work.saturating_sub(1); + if waiting.get(*j).is_some_and(|w| *w > 0) { + continue; + } + if s == e && p.len() > 2 { + if box_dist2(at, *b) >= best.3 { + continue; + } + *work = work.saturating_sub(p.len() as u64); + if let Some((k, d)) = p.iter().enumerate().map(|(k, q)| (k, dist2(at, *q))).min_by_key(|(_, d)| *d) + && d < best.3 + { + best = (i, false, Some(k), d); + } + continue; + } + let (ds, de) = (dist2(at, s), dist2(at, e)); + if ds < best.3 { + best = (i, false, None, ds); + } + if de < best.3 { + best = (i, true, None, de); + } + } + // Spent, or no path free to draw (which only a fault could cause): the rest in paint + // order, each after the paths inside it. + if *work == 0 || best.0 >= left.len() { + match &outer { + Some(outer) => { + warn(warnings, UNSORTED_INSIDE); + inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); + } + None => { + warn(warnings, UNSORTED); + paths.extend(left.into_iter().map(|(_, p, _)| p)); + } + } + return at; + } + let (j, mut p, _) = left.remove(best.0); + for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w = w.saturating_sub(1); + } + } + if best.1 { + p.reverse(); + } else if let Some(k) = best.2 { + // A closed path starts at its point nearest the pen. + p.pop(); + let k = k.min(p.len().saturating_sub(1)); + p.rotate_left(k); + if let Some(&s) = p.first() { + p.push(s); + } + } + if let Some(&e) = p.last() { + at = e; + } + paths.push(p); + } + at +} + +/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order, +/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting` +/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside +/// larger ones, so every path comes free; a path left waiting by a fault is drawn last. +fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec], waiting: &mut [usize], paths: &mut Vec>) { + let mut held: Vec>> = (0..outer.len()).map(|_| None).collect(); + let mut free = BinaryHeap::new(); + for (j, p) in left { + if let Some(h) = held.get_mut(j) { + if waiting.get(j).is_none_or(|w| *w == 0) { + free.push(Reverse(j)); + } + *h = Some(p); + } else { + paths.push(p); + } + } + while let Some(Reverse(j)) = free.pop() { + let Some(p) = held.get_mut(j).and_then(Option::take) else { continue }; + paths.push(p); + for &o in outer.get(j).into_iter().flatten() { + if let Some(w) = waiting.get_mut(o) { + *w = w.saturating_sub(1); + if *w == 0 { + free.push(Reverse(o)); + } + } + } + } + paths.extend(held.into_iter().flatten()); +} + +/// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`. +fn plain(by_pen: &[Vec>]) -> Vec { + let mut s = String::from("IN;"); + for (i, paths) in by_pen.iter().enumerate().filter(|(_, p)| !p.is_empty()) { + s.push_str(&format!("PU;SP{};", i + 1)); + for path in paths { + let Some(((x, y), rest)) = path.split_first() else { continue }; + s.push_str(&format!("PU{x},{y};PD")); + for (k, (x, y)) in rest.iter().enumerate() { + if k > 0 { + s.push(','); + } + s.push_str(&format!("{x},{y}")); + } + s.push(';'); + } + } + s.push_str("PU;SP0;"); + s.into_bytes() +} + +/// HP-GL/2: `IN;`, the palette, each pen's paths as one `PE`, `PU;SP0;`. +fn encoded(by_pen: &[Vec>], pens: &[[u8; 3]]) -> Vec { + let mut out = b"IN;".to_vec(); + if !pens.is_empty() { + // Pens 1 to n on a plotter, and on a raster device (whose pens are 0 to NP − 1) too. + let np = (pens.len() + 1).next_power_of_two().max(2); + out.extend(format!("NP{np};").bytes()); + for (i, [r, g, b]) in pens.iter().enumerate() { + out.extend(format!("PC{},{r},{g},{b};", i + 1).bytes()); + } + } + let mut at: Option<(i64, i64)> = None; + for (i, paths) in by_pen.iter().enumerate().filter(|(_, p)| !p.is_empty()) { + out.extend(format!("PU;SP{};PE", i + 1).bytes()); + for path in paths { + for (k, &p) in path.iter().enumerate() { + if k == 0 { + out.push(b'<'); + } + // The first move of the plot is absolute, and so is one longer than the range a + // device takes for each number; every other is relative. + match at.map(|a| (p.0 - a.0, p.1 - a.1)).filter(|d| d.0.abs() <= MAX_COORD && d.1.abs() <= MAX_COORD) { + None => { + out.push(b'='); + pe_number(&mut out, p.0); + pe_number(&mut out, p.1); + } + Some((dx, dy)) => { + pe_number(&mut out, dx); + pe_number(&mut out, dy); + } + } + at = Some(p); + } + } + out.push(b';'); + } + out.extend(b"PU;SP0;"); + out +} + +/// `v` as a base-64 PE number: the sign in the low bit, the low digits from 63 up and the last +/// digit from 191 up. +fn pe_number(out: &mut Vec, v: i64) { + let mut n = (v.unsigned_abs() << 1) | u64::from(v < 0); + while n >= 64 { + out.push(63 + (n % 64) as u8); + n /= 64; + } + out.push(191 + n as u8); +} + +/// One line the pen draws, in document space, and its color. +struct Run { + rgb: [u8; 3], + points: Vec, +} + +/// The document walk: each visible path becomes runs, clipped, in paint order. +struct Walk<'a> { + doc: &'a Document, + symbols: SymbolBoxes<'a>, + /// The region the art is cut at. + region: Rect, + edge: Option, + /// The plotter's range and the region asked for, when that reaches past the range. + range: Option<(Rect, Rect)>, + /// The region asked for, to cut a line at before testing it against the range. + asked: Option, + tolerance: f64, + /// The clipping masks around the current object, outermost first. + clips: Vec, + /// The points those masks hold ([`MAX_MASK_POINTS`]). + mask_points: usize, + runs: Vec, + points: usize, + clip_tests: u64, + warnings: Vec, + nest: u32, + /// Objects the symbol instances written held, together ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). + symbol_nodes: u64, + /// Objects the live art written made, together ([`MAX_GENERATED`]). + generated: u64, + /// A copy of the document without its layers, to bake symbol art in ([`Walk::baked`]). + scratch: Option, + /// The symbols whose art holds geometry effects ([`Walk::baked`]), or instances of such + /// symbols. + effect_symbols: HashSet, +} + +impl<'a> Walk<'a> { + fn new(doc: &'a Document, region: Rect, tolerance: f64) -> Self { + Self { + doc, + symbols: doc.symbol_boxes().quick(), + region, + edge: Region::new(vec![corners(region)], FillRule::NonZero), + range: None, + asked: None, + tolerance, + clips: vec![], + mask_points: 0, + runs: vec![], + points: 0, + clip_tests: 0, + warnings: vec![], + nest: 0, + symbol_nodes: 0, + generated: 0, + scratch: None, + effect_symbols: effect_symbols(doc), + } + } + + fn warn(&mut self, w: &str) { + warn(&mut self.warnings, w); + } + + fn full(&self) -> bool { + self.points >= MAX_POINTS + } + + fn node(&mut self, n: &Node, force: bool) { + if n.is_ruler_guide() || n.is_template() { + return; + } + if self.full() { + self.warn(TOO_MANY_POINTS); + return; + } + if !force && !n.visible { + return; + } + // Art at no opacity shows nothing, and nor does anything inside it. + if n.opacity <= 0.0 { + return; + } + // Bounds don't measure the effects in symbol art, which apply only once an instance is + // placed: an instance of such art, and what holds one, is not skipped by its bounds. + let unmeasured = !self.effect_symbols.is_empty() && holds_instance_of(n, &self.effect_symbols); + // The box round all the object can paint, a blend's steps on its spine included. + if !n.is_layer() && !unmeasured { + match n.reach_bounds_with(&self.symbols) { + Some(b) if !overlaps(b, self.region) => { + // Art over the asked-for region but past the plotter's range is cut off. + if self.range.is_some_and(|(_, whole)| overlaps(b, whole)) { + self.warn(OUT_OF_RANGE); + } + return; + } + None if !n.is_container() => return, + _ => {} + } + } + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(MASKS); + } + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); + if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) { + self.warn(RASTER_FX); + } + match &n.kind { + // Non-printing layers aren't plotted. + NodeKind::Layer { children, printable, clip, .. } => { + if *printable { + self.children(children, *clip); + } + } + NodeKind::Group { children, clip } => self.children(children, *clip), + NodeKind::Path { path, guide, .. } => { + if !*guide { + self.shape(n, &path.to_bezpath()); + } + } + NodeKind::Compound { children, .. } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + self.shape(n, &bp); + } + NodeKind::Text(t) => { + if t.runs.iter().any(|r| r.inline.is_some()) { + self.warn(INLINE_GRAPHICS); + } + if let Some(o) = vectorcraft_effects::outline_text(n) { + self.warn(TEXT_OUTLINES); + self.node(&o, true); + } + } + NodeKind::Image(_) => self.warn(IMAGES), + NodeKind::SymbolInstance { symbol, xf } => self.symbol(n, symbol, *xf), + // A gradient mesh is its outline, in the average of its shown points' colors: its + // patches are shading, not lines. A mesh whose every point is at 0% opacity shows + // nothing. + NodeKind::Mesh(m) => { + let shown: Vec<[u8; 3]> = m.points.iter().filter(|p| p.opacity > 0.0).map(|p| rgb8(&p.color)).collect(); + if !shown.is_empty() { + self.draw(average(shown.into_iter()), &m.outline().to_bezpath(), true); + } + } + // Live blends, envelopes and repeats are drawn as their evaluated art, a level at a + // time, so a gradient mesh a blend makes is its outline too. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + if self.nest >= MAX_NEST { + self.warn(TOO_DEEP); + return; + } + // Once the budget is spent, nothing more is evaluated. + if self.generated >= MAX_GENERATED { + self.warn(LIVE_LEFT_OUT); + return; + } + let art = vectorcraft_effects::expand_live(Some(self.doc), n); + let count = art.iter().map(|c| c.count() as u64).sum::(); + if self.generated.saturating_add(count) > MAX_GENERATED { + self.generated = MAX_GENERATED; + self.warn(LIVE_LEFT_OUT); + return; + } + self.generated += count; + self.nest += 1; + art.iter().for_each(|c| self.node(c, false)); + self.nest -= 1; + } + NodeKind::RulerGuide { .. } => {} + } + } + + /// A symbol instance's art, placed (clip groups included), within the budget for the export. + fn symbol(&mut self, n: &Node, symbol: &str, xf: Affine) { + let doc = self.doc; + if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { + self.warn(SYMBOLS_LEFT_OUT); + return; + } + let Some(sym) = doc.symbols.iter().find(|s| s.name == symbol) else { return }; + if self.nest >= MAX_NEST { + self.warn(TOO_DEEP); + return; + } + // The instance's own changes and its stain, as the canvas draws it. + let art = vectorcraft_brush::instance_art(&sym.art, n); + let count = art.count_with_masks() as u64; + let most = doc.symbol_slice_map(symbol, xf).map_or(count, |m| m.most_placed(&art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let placed = doc.place_symbol_art(symbol, &art, xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }); + match placed { + Some(Some(art)) => { + let art = self.baked(art); + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + Some(None) => {} + None => { + self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES; + self.warn(SYMBOLS_LEFT_OUT); + } + } + } + + /// Placed symbol art with its geometry effects applied: the document's bake reaches the + /// layers, not the symbols' art. + fn baked(&mut self, art: Node) -> Node { + if !vectorcraft_effects::needs_bake(&art) { + return art; + } + // Guides and layers left out go first, as for the document's layers. + let art = Arc::new(art); + let art = match plotted(std::slice::from_ref(&art), false) { + Some(mut kept) => match kept.pop() { + Some(a) => a, + None => return Node::group(vectorcraft_doc::NodeId(0), vec![]), + }, + None => art, + }; + let art = Arc::unwrap_or_clone(art); + let doc = self.doc; + let scratch = self.scratch.get_or_insert_with(|| { + let mut d = doc.clone(); + d.layers.clear(); + d + }); + scratch.layers = vec![Arc::new(art)]; + let baked = vectorcraft_effects::bake_document(scratch).and_then(|b| b.layers.into_iter().next()); + let art = std::mem::take(&mut scratch.layers).into_iter().next(); + baked.or(art).map(Arc::unwrap_or_clone).unwrap_or_else(|| Node::group(vectorcraft_doc::NodeId(0), vec![])) + } + + /// A container's children. A clipping container's first child cuts the rest; it is drawn + /// itself, once and whole, when it paints: where its stroke paints (over the clipped art) when + /// it has one, else where its fill paints (below it). Nothing to clip by hides the clipped art, + /// as on the canvas. + fn children(&mut self, children: &[Arc], clip: bool) { + let Some((first, rest)) = children.split_first().filter(|_| clip) else { + children.iter().for_each(|c| self.node(c, false)); + return; + }; + let mut paint = first.clip_paint(); + // A stroke at 0% opacity isn't drawn, so the clipping path is drawn in its fill. + let type_stroke = |s: &Node| matches!(&s.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.has_stroke())); + paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s)); + if paint.stroke.is_none() + && let Some(fill) = &paint.fill + { + self.node(fill, false); + } + match vectorcraft_effects::clip_outline(first).map(|(bp, rule)| (self.rings(&bp), rule)) { + Some((Some(rings), rule)) => { + let held: usize = rings.iter().map(Vec::len).sum(); + if let Some(region) = Region::new(rings, rule) { + self.mask_points += held; + self.clips.push(region); + rest.iter().for_each(|c| self.node(c, false)); + self.clips.pop(); + self.mask_points -= held; + } + } + // A mask past the budget isn't built: the art it would clip is drawn unclipped by it. + Some((None, _)) => { + self.warn(MASK_POINTS); + rest.iter().for_each(|c| self.node(c, false)); + } + None => {} + } + // Drawn whole where its stroke paints: a fill closes its outline, and type's characters + // without a stroke are drawn too. + if paint.stroke.is_some() { + self.node(first, false); + } + } + + /// `bp` flattened into closed rings for a clipping mask; `None` when they would take the + /// masks around it past [`MAX_MASK_POINTS`]. + fn rings(&self, bp: &BezPath) -> Option>> { + let left = MAX_MASK_POINTS.saturating_sub(self.mask_points); + let (mut rings, whole) = self.flatten_within(bp, true, left); + if !whole || rings.iter().map(Vec::len).sum::() > left { + return None; + } + for r in &mut rings { + r.pop(); + } + Some(rings) + } + + /// A path drawn in its color: the topmost stroke's that paints, else the topmost fill's that + /// paints (visible, with a paint and some opacity). A filled path's every subpath is closed, + /// as its fill closes it, also when its stroke gives the color. + fn shape(&mut self, n: &Node, bp: &BezPath) { + if bp.elements().is_empty() { + return; + } + let fill = top_fill(n); + let paint = match (top_stroke(n), fill) { + (Some(s), _) => { + if s.dash.as_ref().is_some_and(|d| d.is_dashed()) + || s.brush.is_some() + || s.profile.is_some() + || s.start_arrow.is_some() + || s.end_arrow.is_some() + { + self.warn(STROKE_LOOK); + } + &s.paint + } + (None, Some(f)) => f, + (None, None) => return, + }; + let rgb = self.flat(paint); + self.draw(rgb, bp, fill.is_some()); + } + + /// `bp` drawn in `rgb`: flattened (with `close` every subpath closed), then cut and added. + fn draw(&mut self, rgb: [u8; 3], bp: &BezPath, close: bool) { + // Bounded by the plot's point budget; each curve by its own segment cap. + let budget = MAX_POINTS.saturating_sub(self.points).saturating_mul(4).max(4); + let (lines, whole) = self.flatten_within(bp, close, budget); + if !whole { + self.warn(TOO_MANY_POINTS); + } + for line in lines { + self.add(rgb, line); + } + } + + /// One color for a paint: a solid color, the average of a gradient's stops, or a pattern's + /// first solid fill (gray without one). + fn flat(&self, p: &Paint) -> [u8; 3] { + match p { + Paint::None => [0; 3], + Paint::Solid { color, .. } => rgb8(color), + Paint::Gradient(g) => average(g.gradient.stops.iter().map(|s| rgb8(&s.color))), + Paint::Pattern { pattern, .. } => { + self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a))).map_or([128; 3], |c| rgb8(&c)) + } + } + } + + /// `bp` as polylines (each subpath one), within the tolerance, stopping once about `budget` + /// moves are made; with `close` every subpath ends at its start, else those that close. Also + /// whether every element of `bp` was flattened. + fn flatten_within(&self, bp: &BezPath, close: bool, budget: usize) -> (Vec>, bool) { + let mut out: Vec> = vec![]; + let mut cur: Vec = vec![]; + let end = |cur: &mut Vec, out: &mut Vec>, closing: bool| { + if (closing || close) + && let Some(&s) = cur.first() + && cur.last() != Some(&s) + { + cur.push(s); + } + out.push(std::mem::take(cur)); + }; + let (made, taken) = (std::cell::Cell::new(0usize), std::cell::Cell::new(0usize)); + let elements = bp.iter().take_while(|_| made.get() < budget).inspect(|_| taken.set(taken.get() + 1)); + vectorcraft_geom::flatten(elements, self.tolerance, |el| { + made.set(made.get() + 1); + match el { + PathEl::MoveTo(p) => { + end(&mut cur, &mut out, false); + cur.push(p); + } + PathEl::LineTo(p) => { + if cur.last() != Some(&p) { + cur.push(p); + } + } + PathEl::ClosePath => { + let s = cur.first().copied(); + end(&mut cur, &mut out, true); + // A subpath that goes on after its close starts where it closed. + if let Some(s) = s { + cur.push(s); + } + } + _ => {} + } + }); + let whole = taken.get() == bp.elements().len(); + // A subpath the budget cut short stays open: a move back to its start isn't on its outline. + if whole { + end(&mut cur, &mut out, false); + } else { + out.push(cur); + } + out.retain(|l| l.len() >= 2); + (out, whole) + } + + /// A polyline drawn in `rgb`, cut by the region and the clipping masks around it. + fn add(&mut self, rgb: [u8; 3], line: Vec) { + // Only the part over the region asked for counts: the rest is cut off anyway. + if let (Some((range, _)), Some(asked)) = (self.range, &self.asked) + && !self.warnings.iter().any(|w| w == OUT_OF_RANGE) + { + let mut unlimited = u64::MAX; + if asked.cut(&line, &mut unlimited).unwrap_or_default().iter().flatten().any(|p| !holds(range, *p)) { + self.warn(OUT_OF_RANGE); + } + } + // The region has four edges, so its cut costs a few tests a point and needs no limit. + let mut unlimited = u64::MAX; + let mut pieces = self.edge.as_ref().and_then(|edge| edge.cut(&line, &mut unlimited)).unwrap_or_default(); + for clip in &self.clips { + let mut left = MAX_CLIP_TESTS.saturating_sub(self.clip_tests); + let cut: Option>>> = pieces.iter().map(|p| clip.cut(p, &mut left)).collect(); + self.clip_tests = MAX_CLIP_TESTS.saturating_sub(left); + match cut { + Some(cut) => pieces = cut.into_iter().flatten().collect(), + None => { + // Spent: this art keeps the parts this mask and the ones inside it would cut, + // and later art that reaches a mask isn't clipped either. + self.clip_tests = MAX_CLIP_TESTS; + warn(&mut self.warnings, CLIP_WORK); + break; + } + } + } + for mut points in pieces { + let left = MAX_POINTS.saturating_sub(self.points); + if points.len() > left { + self.warn(TOO_MANY_POINTS); + points.truncate(left); + } + if points.len() < 2 { + return; + } + self.points += points.len(); + self.runs.push(Run { rgb, points }); + } + } +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// `nodes` (the children of a clipping container when `clipping`) without the layers and +/// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without +/// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn +/// (its Crop Marks); `None` when nothing changes. A guide that is a clipping path keeps its place +/// without its effects. +fn plotted(nodes: &[Arc], clipping: bool) -> Option>> { + let mut changed = false; + let mut out = Vec::with_capacity(nodes.len()); + for (i, n) in nodes.iter().enumerate() { + match &n.kind { + NodeKind::Layer { printable, template, .. } if !(n.visible && n.opacity > 0.0 && *printable && !*template) => { + changed = true; + continue; + } + NodeKind::Path { guide: true, .. } => { + changed = true; + if clipping && i == 0 { + let mut g = (**n).clone(); + g.appearance.effects.clear(); + out.push(Arc::new(g)); + } + continue; + } + _ => {} + } + let inner = match &n.kind { + NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } => plotted(children, *clip), + _ => None, + }; + match inner { + Some(children) => { + changed = true; + let mut c = (**n).clone(); + if let Some(ch) = c.children_mut() { + *ch = children; + } + out.push(Arc::new(c)); + } + None => out.push(n.clone()), + } + } + changed.then_some(out) +} + +/// The symbols of `doc` whose art holds geometry effects, and those whose art holds instances of +/// them, as deep as symbols are drawn ([`MAX_NEST`]). +fn effect_symbols(doc: &Document) -> HashSet { + let mut set: HashSet = doc.symbols.iter().filter(|s| vectorcraft_effects::needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + for _ in 0..MAX_NEST { + if set.is_empty() { + break; + } + let more: Vec = + doc.symbols.iter().filter(|s| !set.contains(&s.name) && holds_instance_of(&s.art, &set)).map(|s| s.name.clone()).collect(); + if more.is_empty() { + break; + } + set.extend(more); + } + set +} + +/// Is `n`, or anything in it, an instance of one of `symbols`? +fn holds_instance_of(n: &Node, symbols: &HashSet) -> bool { + match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.contains(symbol), + _ => n.children().into_iter().flatten().any(|c| holds_instance_of(c, symbols)), + } +} + +fn rgb8(c: &vectorcraft_color::Color) -> [u8; 3] { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] +} + +/// The average of `colors` (black without any). +fn average(colors: impl Iterator) -> [u8; 3] { + let (sum, n) = colors.fold(([0.0; 3], 0.0), |(a, n), c| ([0, 1, 2].map(|i| a[i] + f64::from(c[i])), n + 1.0)); + sum.map(|v| (v / f64::max(n, 1.0)).round().clamp(0.0, 255.0) as u8) +} + +/// The first visible solid fill color in `n` (a pattern tile's art), hidden objects and objects +/// or fills at 0% opacity left out. +fn first_solid(n: &Node) -> Option { + if !n.visible || n.opacity <= 0.0 { + return None; + } + let own = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 => f.paint.color(), + _ => None, + }); + own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) +} + +/// The topmost stroke of `n` that paints: visible, with a paint, a weight and some opacity. +fn top_stroke(n: &Node) -> Option<&StrokeLayer> { + n.appearance.items.iter().rev().find_map(|i| match i { + AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => Some(s), + _ => None, + }) +} + +/// The paint of the topmost fill of `n` that paints: visible, with a paint and some opacity. +fn top_fill(n: &Node) -> Option<&Paint> { + n.appearance.items.iter().rev().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0 => Some(&f.paint), + _ => None, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/hpgl/write/clip.rs b/crates/cad/src/hpgl/write/clip.rs new file mode 100644 index 0000000000..235032ee2b --- /dev/null +++ b/crates/cad/src/hpgl/write/clip.rs @@ -0,0 +1,265 @@ +//! Polylines cut by a region: each segment is split where it crosses the region's edges, and the +//! pieces whose middle lies inside are kept, joined where they meet. + +use vectorcraft_geom::{FillRule, Point, Rect}; + +/// A clipping region: closed rings (each closes implicitly) and the rule that decides inside. +pub(super) struct Region { + rings: Vec>, + rule: FillRule, + bounds: Rect, +} + +impl Region { + /// The region of `rings` under `rule`; `None` when it has no ring of three points or more. + pub fn new(rings: Vec>, rule: FillRule) -> Option { + let rings: Vec> = rings.into_iter().filter(|r| r.len() >= 3).collect(); + let bounds = rings.iter().flatten().fold(None, |b: Option, p| Some(b.map_or(Rect::from_points(*p, *p), |b| b.union_pt(*p))))?; + Some(Self { rings, rule, bounds }) + } + + /// The edges this region has, which [`Region::cut`] tests each segment against. + fn edges(&self) -> u64 { + self.rings.iter().map(|r| r.len() as u64).sum() + } + + /// Does `p` lie on one of the region's edges (within a millionth of a point)? + fn on_edge(&self, p: Point) -> bool { + self.rings.iter().any(|ring| { + ring.iter().enumerate().any(|(i, &a)| { + let b = ring[(i + 1) % ring.len()]; + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(p) <= 1e-6 + }) + }) + } + + /// Is `p` inside (non-zero winding or an odd number of crossings), or on an edge? + fn contains(&self, p: Point) -> bool { + if self.on_edge(p) { + return true; + } + let mut winding = 0i64; + for ring in &self.rings { + for (i, &a) in ring.iter().enumerate() { + let b = ring[(i + 1) % ring.len()]; + if a.y <= p.y { + if b.y > p.y && cross(a, b, p) > 0.0 { + winding += 1; + } + } else if b.y <= p.y && cross(a, b, p) < 0.0 { + winding -= 1; + } + } + } + match self.rule { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + } + } + + /// Where segment `a`–`b` crosses this region's edges, as fractions of its length strictly + /// between 0 and 1, sorted. + fn crossings(&self, a: Point, b: Point) -> Vec { + let mut ts = vec![]; + for ring in &self.rings { + for (i, &c) in ring.iter().enumerate() { + let d = ring[(i + 1) % ring.len()]; + if let Some(t) = intersect(a, b, c, d) + && t > 1e-12 + && t < 1.0 - 1e-12 + { + ts.push(t); + } + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12); + ts + } + + /// The parts of polyline `line` inside the region, each a polyline of two points or more. + /// Every test of a point or a segment against an edge takes one from `budget`; `None` when + /// the budget runs out first. + pub fn cut(&self, line: &[Point], budget: &mut u64) -> Option>> { + let edges = self.edges(); + let mut out: Vec> = vec![]; + let mut cur: Vec = vec![]; + for w in line.windows(2) { + let (a, b) = (w[0], w[1]); + // A segment wholly off the region's box lies outside it. + let near = overlaps(Rect::from_points(a, b), self.bounds); + let ts = if near { + spend(budget, edges)?; + self.crossings(a, b) + } else { + vec![] + }; + let mut from = 0.0; + for to in ts.into_iter().chain([1.0]) { + let (p, q) = (a.lerp(b, from), a.lerp(b, to)); + // Each piece between crossings is tested on its own: its edge and winding tests. + let inside = if near { + spend(budget, edges.saturating_mul(2))?; + self.contains(p.midpoint(q)) + } else { + false + }; + if inside { + if cur.last().is_none_or(|l| l.distance(p) > 1e-9) { + flush(&mut out, &mut cur); + cur.push(p); + } + cur.push(q); + } else { + flush(&mut out, &mut cur); + } + from = to; + } + } + flush(&mut out, &mut cur); + // A closed line that leaves the region and comes back: its last piece ends where its + // first starts, at the line's start, and the two are one piece. + if line.len() > 2 + && line.first() == line.last() + && out.len() >= 2 + && let (Some(end), Some(start)) = (out.last().and_then(|l| l.last()), out.first().and_then(|l| l.first())) + && end.distance(*start) <= 1e-9 + { + let first = out.remove(0); + if let Some(last) = out.last_mut() { + last.extend(first.into_iter().skip(1)); + } + } + Some(out) + } +} + +/// Take `n` from `budget`; `None` when it holds less. +fn spend(budget: &mut u64, n: u64) -> Option<()> { + *budget = budget.checked_sub(n)?; + Some(()) +} + +fn flush(out: &mut Vec>, cur: &mut Vec) { + let line = std::mem::take(cur); + if line.len() >= 2 { + out.push(line); + } +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Which side of line `a`–`b` point `p` lies on (positive: left). +fn cross(a: Point, b: Point, p: Point) -> f64 { + (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y) +} + +/// Where segment `a`–`b` crosses segment `c`–`d`, as a fraction of `a`–`b`; `None` when they +/// are parallel or don't meet. +fn intersect(a: Point, b: Point, c: Point, d: Point) -> Option { + let (r, s) = (b - a, d - c); + let den = r.x * s.y - r.y * s.x; + if den.abs() <= f64::EPSILON * (r.hypot() * s.hypot()).max(f64::MIN_POSITIVE) { + return None; + } + let ac = c - a; + let t = (ac.x * s.y - ac.y * s.x) / den; + let u = (ac.x * r.y - ac.y * r.x) / den; + ((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)).then_some(t) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { + vec![Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)] + } + + /// `line` cut by `r` with no limit on the work. + fn cut(r: &Region, line: &[Point]) -> Vec> { + let mut unlimited = u64::MAX; + r.cut(line, &mut unlimited).unwrap() + } + + #[test] + fn a_line_across_a_square_keeps_its_middle() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let pieces = cut(&r, &[Point::new(-5.0, 5.0), Point::new(15.0, 5.0)]); + assert_eq!(pieces, vec![vec![Point::new(0.0, 5.0), Point::new(10.0, 5.0)]]); + // Wholly outside, nothing; wholly inside, the line as it is. + assert!(cut(&r, &[Point::new(20.0, 0.0), Point::new(30.0, 0.0)]).is_empty()); + let inside = vec![Point::new(1.0, 1.0), Point::new(9.0, 1.0), Point::new(9.0, 9.0)]; + assert_eq!(cut(&r, &inside), vec![inside]); + } + + /// Each piece between crossings costs a pass over the edges: a line through the teeth of a + /// comb spends far more than its two points times the edges. + #[test] + fn every_piece_between_crossings_is_paid_for() { + let mut ring = vec![]; + for i in 0..100 { + let x = f64::from(i) * 4.0; + ring.extend([Point::new(x, 0.0), Point::new(x + 2.0, 0.0), Point::new(x + 2.0, 10.0), Point::new(x + 4.0, 10.0)]); + } + ring.extend([Point::new(400.0, 20.0), Point::new(0.0, 20.0)]); + let r = Region::new(vec![ring], FillRule::NonZero).unwrap(); + let line = [Point::new(-1.0, 5.0), Point::new(401.0, 5.0)]; + // 402 edges, 200 crossings, 201 pieces: 402 + 201 × 2 × 402 tests. + let need = 402 + 201 * 2 * 402; + let mut budget = need; + assert_eq!(r.cut(&line, &mut budget).map(|p| p.len()), Some(100)); + assert_eq!(budget, 0); + let mut short = need - 1; + assert!(r.cut(&line, &mut short).is_none()); + // A segment off the region's box costs nothing. + assert_eq!(r.cut(&[Point::new(0.0, 30.0), Point::new(9.0, 30.0)], &mut 0), Some(vec![])); + } + + /// A line along any of the region's edges is inside it. + #[test] + fn a_line_on_an_edge_is_kept() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + for line in [ + [Point::new(0.0, 0.0), Point::new(10.0, 0.0)], + [Point::new(10.0, 0.0), Point::new(10.0, 10.0)], + [Point::new(10.0, 10.0), Point::new(0.0, 10.0)], + [Point::new(0.0, 10.0), Point::new(0.0, 0.0)], + ] { + assert_eq!(cut(&r, &line), vec![line.to_vec()], "{line:?}"); + } + } + + #[test] + fn a_line_leaving_and_coming_back_is_two_pieces() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0)]; + assert_eq!(cut(&r, &line), vec![vec![Point::new(5.0, 5.0), Point::new(10.0, 5.0)], vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0)]]); + } + + /// A closed line that starts inside, leaves and comes back is one piece: the part after it + /// comes back runs on into the part before it left. + #[test] + fn a_closed_line_cut_once_stays_one_piece() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0)]; + assert_eq!(cut(&r, &line), vec![vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0), Point::new(10.0, 5.0)]]); + } + + #[test] + fn the_fill_rule_decides_a_hole() { + let rings = vec![square(0.0, 0.0, 10.0, 10.0), square(3.0, 3.0, 7.0, 7.0)]; + let line = [Point::new(-1.0, 5.0), Point::new(11.0, 5.0)]; + // Both rings wind the same way: even-odd cuts a hole, non-zero doesn't. + let even_odd = cut(&Region::new(rings.clone(), FillRule::EvenOdd).unwrap(), &line); + assert_eq!(even_odd, vec![vec![Point::new(0.0, 5.0), Point::new(3.0, 5.0)], vec![Point::new(7.0, 5.0), Point::new(10.0, 5.0)]]); + let non_zero = cut(&Region::new(rings, FillRule::NonZero).unwrap(), &line); + assert_eq!(non_zero.len(), 1); + assert!(Region::new(vec![vec![Point::ZERO, Point::new(1.0, 1.0)]], FillRule::NonZero).is_none()); + } +} diff --git a/crates/cad/src/hpgl/write/nest.rs b/crates/cad/src/hpgl/write/nest.rs new file mode 100644 index 0000000000..eee4630ff3 --- /dev/null +++ b/crates/cad/src/hpgl/write/nest.rs @@ -0,0 +1,357 @@ +//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the +//! inner paths first. + +/// The box of `p`: `[x0, y0, x1, y1]`. +pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] { + p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]) +} + +/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of +/// its segments crosses the closed path's line, each part of it between the points where it meets +/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one +/// part lies inside. A path touching the line of the one around it is inside it, a path crossing +/// it is not, and copies of one outline are not inside each other. A path is tested only against +/// closed paths larger than it (by the area of their box, then the area they enclose, then paint +/// order), so no two paths are each inside the other, and only against the closed paths in the +/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a +/// box compared cost one each, and each segment tested costs the closed path's points for its +/// crossings and again for each part of it. +pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option>> { + let size: Vec<(i128, i128)> = paths + .iter() + .zip(boxes) + .map(|(p, b)| { + let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1])); + (box_area, if closed(p) { twice_area(p) } else { 0 }) + }) + .collect(); + let rings: Vec = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect(); + let mut out = vec![vec![]; paths.len()]; + if rings.is_empty() { + return Some(out); + } + let grid = Grid::new(&rings, boxes, work)?; + for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() { + for &r in grid.at(ab[0], ab[1]) { + spend(work, 1)?; + let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue }; + let smaller = (size.get(a), a) < (size.get(r), r); + if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] { + continue; + } + if inside(p, ring, work)? + && let Some(o) = out.get_mut(a) + { + o.push(r); + } + } + } + Some(out) +} + +/// The most cells along each side of a [`Grid`]. +const MAX_GRID_SIDE: usize = 512; +/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed +/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more +/// entries than the pen has points. +const MAX_CELLS_PER_PATH: usize = 4; + +/// A grid over the boxes of a pen's closed paths, about one cell for each, up to +/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers +/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed +/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell. +struct Grid { + /// The first corner of the closed paths' boxes together. + x0: i128, + y0: i128, + /// Their width and height, plus one. + w: i128, + h: i128, + /// Cells along each side. + side: usize, + /// The cells row by row. + cells: Vec>, + /// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells. + wide: Vec, +} + +impl Grid { + /// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent. + fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option { + let all = rings + .iter() + .filter_map(|&r| boxes.get(r)) + .fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]); + let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE); + let (x0, y0) = (i128::from(all[0]), i128::from(all[1])); + let mut grid = Self { + x0, + y0, + w: (i128::from(all[2]) - x0 + 1).max(1), + h: (i128::from(all[3]) - y0 + 1).max(1), + side, + cells: vec![vec![]; side * side], + wide: vec![], + }; + for &r in rings { + let Some(b) = boxes.get(r) else { continue }; + let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue }; + let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if covered > MAX_CELLS_PER_PATH { + spend(work, 1)?; + grid.wide.push(r); + continue; + } + spend(work, covered as u64)?; + for cy in cy0..=cy1 { + for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() { + cell.push(r); + } + } + } + Some(grid) + } + + /// The column and row of the cell holding `(x, y)`, `None` outside the grid. + fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> { + let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0); + if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h { + return None; + } + let side = self.side as i128; + Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize)) + } + + /// The wide closed paths, then those listed in the cell holding `(x, y)`. + fn at(&self, x: i64, y: i64) -> impl Iterator { + let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice); + self.wide.iter().chain(cell) + } +} + +/// Takes `n` from `work`; `None`, with `work` at 0, when it has less. +fn spend(work: &mut u64, n: u64) -> Option<()> { + let left = work.checked_sub(n); + *work = left.unwrap_or(0); + left.map(|_| ()) +} + +/// Does `p` start and end at one point, with three other points or more? +fn closed(p: &[(i64, i64)]) -> bool { + p.len() > 3 && p.first() == p.last() +} + +/// Twice the area the closed path `p` encloses. +fn twice_area(p: &[(i64, i64)]) -> i128 { + let sum: i128 = p + .windows(2) + .map(|w| match w { + [a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1), + _ => 0, + }) + .sum(); + sum.abs() +} + +/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent. +fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option { + let mut any = false; + for w in p.windows(2) { + let [s, e] = w else { continue }; + spend(work, ring.len() as u64)?; + // Where the segment meets the line without crossing it: its ends and the ring's points on + // it. Between two of these the segment is all inside or all outside. + let mut stops = vec![*s, *e]; + for edge in ring.windows(2) { + let [a, b] = edge else { continue }; + if crosses((*s, *e), (*a, *b)) { + return Some(false); + } + if a != s && a != e && on((*s, *e), *a) { + stops.push(*a); + } + } + let along = |q: &(i64, i64)| { + (i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0)) + + (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1)) + }; + stops.sort_by_key(along); + stops.dedup(); + for part in stops.windows(2) { + let [m, n] = part else { continue }; + spend(work, ring.len() as u64)?; + // The part's middle, doubled so it is whole. + match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) { + Some(false) => return Some(false), + Some(true) => any = true, + None => {} + } + } + } + Some(any) +} + +/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it. +fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 { + let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1)) + - (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0)); + d.signum() +} + +/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other +/// to the other side? +fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool { + side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0 +} + +/// Does `q` lie on segment `u`? +fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool { + let ((x0, y0), (x1, y1)) = u; + side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1) +} + +/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the +/// even-odd rule, `Some(false)` outside, `None` on its line. +fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option { + let mut inside = false; + for w in ring.windows(2) { + let [a, b] = w else { continue }; + let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1))); + // Which side of the segment's line `q` is on, 0 on it. + let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0); + if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) { + return None; + } + // A segment crossing the level of `q` to its right. + if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) { + inside = !inside; + } + } + Some(inside) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> { + vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + } + + fn nest(paths: &[Vec<(i64, i64)>]) -> Option>> { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = u64::MAX; + outer(paths, &boxes, &mut work) + } + + #[test] + fn points_inside_outside_and_on_the_line() { + let ring = square(0, 0, 10, 10); + assert_eq!(locate((10, 10), &ring), Some(true)); + assert_eq!(locate((30, 10), &ring), Some(false)); + assert_eq!(locate((10, -2), &ring), Some(false)); + assert_eq!(locate((20, 10), &ring), None); + assert_eq!(locate((0, 0), &ring), None); + } + + #[test] + fn a_path_inside_another_lies_inside_it_and_not_the_other_way() { + let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]])); + } + + #[test] + fn touching_counts_as_inside_and_crossing_does_not() { + // A square with a corner on the outline's left edge, a triangle whose corners lie on the + // outline's edges, and a square that crosses the outline. + let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]])); + } + + /// Every point of a copy lies on the other's line, and none inside it. + #[test] + fn copies_of_one_outline_do_not_lie_inside_each_other() { + let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]])); + } + + /// A line whose ends and middle lie inside an outline but which crosses a slot cut into it. + #[test] + fn a_line_across_a_slot_is_not_inside() { + let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + } + + /// A line that leaves an outline and comes back through two of its corners, crossing none of + /// its sides, and one that runs along the bottom of a slot. + #[test] + fn a_line_through_corners_is_inside_only_where_it_stays_inside() { + let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]])); + } + + /// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its + /// lobes lies inside it. + #[test] + fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() { + let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)]; + assert_eq!(twice_area(&bow), 0); + assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]])); + } + + #[test] + fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() { + let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)]; + assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]])); + } + + /// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths + /// near it, not against every part. + #[test] + fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() { + let mut paths = vec![]; + for row in 0..100 { + for col in 0..100 { + let (x, y) = (col * 30, row * 30); + paths.push(square(x, y, x + 20, y + 20)); + paths.push(square(x + 5, y + 5, x + 15, y + 15)); + } + } + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 5_000_000; + let found = outer(&paths, &boxes, &mut work); + let want: Vec> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect(); + assert_eq!(found, Some(want)); + } + + /// 400 closed paths that each cover most of the grid are listed once each, not in every cell + /// they cover. + #[test] + fn closed_paths_covering_many_cells_are_listed_once() { + let mut paths: Vec> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect(); + paths.extend((0..400).map(|i| { + let (x, y) = ((i % 20) * 50, (i / 20) * 50); + square(x, y, x + 10, y + 10) + })); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let rings: Vec = (0..paths.len()).collect(); + let mut work = u64::MAX; + let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") }; + assert_eq!(grid.wide, (0..400).collect::>()); + assert!(grid.cells.iter().map(Vec::len).sum::() <= 400 * MAX_CELLS_PER_PATH); + } + + #[test] + fn finding_what_lies_inside_stops_at_its_budget() { + let paths: Vec> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect(); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 1_000; + assert_eq!(outer(&paths, &boxes, &mut work), None); + assert_eq!(work, 0); + let mut work = u64::MAX; + let found = outer(&paths, &boxes, &mut work); + assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::()), Some(50 * 49 / 2)); + } +} diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs new file mode 100644 index 0000000000..c29ef67532 --- /dev/null +++ b/crates/cad/src/hpgl/write/tests.rs @@ -0,0 +1,1109 @@ +//! HP-GL export: the instructions written, pens, origins, what is cut and left out, limits, and +//! plots read back by the HP-GL reader. + +use std::sync::Arc; + +use kurbo::{PathEl, Shape as _}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, LayerColor, Node, NodeKind, TextObject}; +use vectorcraft_geom::{PathData, Point, Rect, shapes}; + +use super::*; + +/// A 200 × 100 pt document whose first layer holds what `nodes` makes. +fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { + let mut d = Document::new(200.0, 100.0); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +fn stroked(d: &mut Document, p: PathData, c: Color) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::solid(c), 1.0)) +} + +fn filled(d: &mut Document, p: PathData, c: Color) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(Paint::solid(c), Paint::None, 1.0)) +} + +fn line(a: (f64, f64), b: (f64, f64)) -> PathData { + let mut bp = BezPath::new(); + bp.move_to(a); + bp.line_to(b); + PathData::from_bezpath(&bp) +} + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData { + shapes::rectangle(Rect::new(x0, y0, x1, y1)) +} + +/// The options with the first artboard as the region. +fn opts(d: &Document) -> HpglOptions { + HpglOptions { region: d.artboards[0].rect, ..HpglOptions::default() } +} + +fn plot(d: &Document, o: &HpglOptions) -> HpglOutput { + match export(d, o) { + Ok(out) => out, + Err(e) => panic!("{e}"), + } +} + +fn text(out: &HpglOutput) -> String { + String::from_utf8(out.bytes.clone()).expect("ASCII") +} + +/// A black square outline, a red line and a blue ellipse filled without a stroke. +fn three() -> Document { + doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK), + stroked(d, line((70.0, 20.0), (150.0, 80.0)), Color::rgb8(255, 0, 0)), + filled(d, shapes::ellipse(Rect::new(70.0, 30.0, 130.0, 70.0)), Color::rgb8(0, 0, 255)), + ] + }) +} + +/// Every instruction in the HP-GL reference guide's form: a two-letter mnemonic, integer +/// parameters separated by commas, and a semicolon (https://support.hp.com/sg-en/document/c01121263). +#[test] +fn a_plot_is_pen_moves_in_plotter_units_from_the_bottom_left() { + let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let out = plot(&d, &opts(&d)); + // 1016 units to the inch, y up from the artboard's bottom edge; the square closes. + assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD847,1270,847,847,141,847,141,1270;PU;SP0;"); + for i in text(&out).split_terminator(';') { + let (op, params) = i.split_at(2); + assert!(op.bytes().all(|c| c.is_ascii_uppercase()), "{i}"); + assert!(params.is_empty() || params.split(',').all(|v| v.parse::().is_ok()), "{i}"); + } + assert_eq!(out.pens, [[0, 0, 0]]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn the_origin_can_be_the_center() { + let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let out = plot(&d, &HpglOptions { origin: Origin::Center, ..opts(&d) }); + // The artboard's center is (100, 50): x from -1270, y from 564 (40 pt up). + assert_eq!(text(&out), "IN;PU;SP1;PU-1270,564;PD-564,564,-564,141,-1270,141,-1270,564;PU;SP0;"); +} + +#[test] +fn each_new_color_is_the_next_pen_and_pens_draw_in_turn() { + let d = doc_with(|d| { + vec![ + stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::rgb8(255, 0, 0)), + stroked(d, line((10.0, 20.0), (20.0, 20.0)), Color::BLACK), + stroked(d, line((10.0, 30.0), (20.0, 30.0)), Color::rgb8(255, 0, 0)), + ] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[255, 0, 0], [0, 0, 0]]); + assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD282,1270;PU141,988;PD282,988;PU;SP2;PU141,1129;PD282,1129;PU;SP0;"); + // A pen table: each color takes the nearest pen's (red is as near black as blue, and black + // comes first). + let table = plot(&d, &HpglOptions { pens: vec![[0, 0, 0], [0, 0, 255]], ..opts(&d) }); + assert_eq!(table.pens, [[0, 0, 0], [0, 0, 255]]); + assert!(text(&table).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&table)); + assert!(!text(&table).contains("SP2"), "{}", text(&table)); +} + +#[test] +fn a_filled_path_is_its_closed_outline_and_a_stroke_its_line() { + let d = doc_with(|d| { + let mut open = BezPath::new(); + open.move_to((10.0, 10.0)); + open.line_to((20.0, 10.0)); + open.line_to((20.0, 20.0)); + let open = PathData::from_bezpath(&open); + let both = Node::path(d.alloc_id(), open.clone(), Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb8(255, 0, 0)), 1.0)); + vec![filled(d, open.clone(), Color::BLACK), stroked(d, open, Color::BLACK), both] + }); + let out = plot(&d, &opts(&d)); + let t = text(&out); + assert!(t.contains("PU141,1270;PD282,1270,282,1129,141,1270;"), "the fill closes: {t}"); + assert!(t.contains("PU141,1270;PD282,1270,282,1129;PU;"), "the stroke doesn't: {t}"); + // A path with a fill and a stroke is closed too, in its stroke's color. + assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]]); + assert!(t.ends_with("SP2;PU141,1270;PD282,1270,282,1129,141,1270;PU;SP0;"), "{t}"); +} + +/// A gradient mesh is its outline, once, in the average of its points' colors. +#[test] +fn a_gradient_mesh_is_its_outline() { + let d = doc_with(|d| { + let mesh = vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(10.0 + u * 50.0, 10.0 + v * 50.0), &|u, _| { + if u < 0.5 { Color::rgb8(255, 0, 0) } else { Color::rgb8(0, 0, 255) } + }); + vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh))] + }); + let out = plot(&d, &opts(&d)); + let t = text(&out); + assert_eq!(t.matches("PD").count(), 1, "{t}"); + assert!(t.starts_with("IN;PU;SP1;PU"), "{t}"); + // The square's four corners are on the line it draws. + for corner in ["141,1270", "847,1270", "847,564", "141,564"] { + assert!(t.contains(corner), "{corner} in {t}"); + } + let [r, g, b] = out.pens[0]; + assert!(r > 0 && b > 0 && g == 0, "{:?}", out.pens); +} + +/// A square gradient mesh, 30 pt a side, at `x`. +fn mesh_at(x: f64) -> vectorcraft_doc::live::GradientMesh { + vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(x + u * 30.0, 10.0 + v * 30.0), &|_, _| Color::BLACK) +} + +fn blend_of(d: &mut Document, keys: Vec, steps: u32) -> Node { + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Steps(steps), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) }) +} + +/// A mesh inside a blend is its outline too: a blend of two meshes with one step between them +/// draws three outlines. A mesh whose every point is at 0% opacity draws nothing. +#[test] +fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() { + let d = doc_with(|d| { + let keys = vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(110.0)))]; + let mut clear = mesh_at(60.0); + clear.points.iter_mut().for_each(|p| p.opacity = 0.0); + vec![blend_of(d, keys, 1), Node::new(d.alloc_id(), NodeKind::Mesh(clear))] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t.matches("PD").count(), 3, "{t}"); +} + +/// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a +/// warning. +#[test] +fn live_art_past_its_limit_is_left_out() { + let d = doc_with(|d| { + let keys = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((190.0, 10.0), (190.0, 20.0)), Color::BLACK)]; + vec![blend_of(d, keys, 1_200)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == LIVE_LEFT_OUT), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// A line shorter than a plotter unit draws nothing and takes no pen. +#[test] +fn a_line_that_draws_nothing_takes_no_pen() { + let d = doc_with(|d| { + vec![stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::rgb8(255, 0, 0)), stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[0, 0, 0]]); + assert!(text(&out).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&out)); + let two = plot(&d, &HpglOptions { version: Version::Hpgl2, ..opts(&d) }); + assert!(two.bytes.starts_with(b"IN;NP2;PC1,0,0,0;PU;SP1;PE"), "{:?}", String::from_utf8_lossy(&two.bytes)); +} + +/// The plots of one export share the pens: a color is the same pen in each, numbered in the +/// order the plots first draw in it. +#[test] +fn the_plots_of_one_export_share_their_pens() { + let d = doc_with(|d| { + vec![ + stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK), + stroked(d, line((110.0, 10.0), (120.0, 10.0)), Color::rgb8(255, 0, 0)), + stroked(d, line((110.0, 20.0), (120.0, 20.0)), Color::BLACK), + ] + }); + let regions = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)]; + let plots = export_regions(&d, &HpglOptions::default(), ®ions).unwrap(); + assert_eq!(plots.len(), 2); + for p in &plots { + assert_eq!(p.pens, [[0, 0, 0], [255, 0, 0]]); + } + assert_eq!(text(&plots[0]), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + assert_eq!(text(&plots[1]), "IN;PU;SP1;PU141,1129;PD282,1129;PU;SP2;PU141,1270;PD282,1270;PU;SP0;"); +} + +#[test] +fn hidden_and_non_printing_art_is_left_out() { + let mut d = doc_with(|d| { + let mut hidden = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK); + hidden.visible = false; + let none = Node::path(d.alloc_id(), rect(30.0, 10.0, 40.0, 20.0), Appearance::basic(Paint::None, Paint::None, 1.0)); + vec![hidden, none] + }); + let mut layer = Node::layer(d.alloc_id(), "Notes", LayerColor::Preset(1)); + if let NodeKind::Layer { printable, .. } = &mut layer.kind { + *printable = false; + } + let art = stroked(&mut d, rect(50.0, 10.0, 60.0, 20.0), Color::BLACK); + layer.children_mut().unwrap().push(Arc::new(art)); + d.layers.push(Arc::new(layer)); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + +/// Hidden and template layers, guides of either kind and placed images draw nothing; an image +/// comes back as a warning. +#[test] +fn layers_guides_and_images_that_draw_nothing_are_left_out() { + let mut d = doc_with(|d| { + let mut guide = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK); + if let NodeKind::Path { guide, .. } = &mut guide.kind { + *guide = true; + } + let ruler = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 50.0, artboard: None }); + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ruler, guide, Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))] + }); + let hidden = d.add_layer(Some("Notes")); + let template = d.add_layer(Some("Tracing")); + for l in [hidden, template] { + let n = stroked(&mut d, rect(30.0, 10.0, 40.0, 20.0), Color::BLACK); + d.insert(Some(l), 0, n).unwrap(); + } + if let Some(n) = d.node_mut(hidden) { + n.visible = false; + } + if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) { + *template = true; + } + let out = plot(&d, &opts(&d)); + assert_eq!(text(&out), "IN;PU;SP0;"); + assert_eq!(out.warnings, [IMAGES]); +} + +/// A non-printing layer's Crop Marks are left out with the layer, and so is a template layer's. +#[test] +fn the_crop_marks_of_a_layer_left_out_are_left_out() { + for template in [false, true] { + let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let layer = d.layers[0].id; + if let Some(n) = d.node_mut(layer) { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + if let NodeKind::Layer { printable, template: t, .. } = &mut n.kind { + (*printable, *t) = (template, template); + } + } + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;", "template: {template}"); + } +} + +/// A guide's Crop Marks are left out with the guide, in the layers and in a symbol's art. +#[test] +fn the_crop_marks_of_a_guide_are_left_out() { + let guide = |d: &mut Document| { + let mut g = stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + g.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + if let NodeKind::Path { guide, .. } = &mut g.kind { + *guide = true; + } + g + }; + let d = doc_with(|d| { + let g = guide(d); + vec![Node::group(d.alloc_id(), vec![Arc::new(g)])] + }); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); + let mut d = Document::new(200.0, 100.0); + let g = guide(&mut d); + let art = Node::group(d.alloc_id(), vec![Arc::new(g)]); + let d = symbol_doc(art, |_| {}); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + +/// Inline graphics in type are left out with a warning. +#[test] +fn inline_graphics_in_type_are_left_out_with_a_warning() { + let d = doc_with(|d| { + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let mut t = TextObject::point(Point::new(10.0, 60.0), "O", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("mark"), style)); + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == INLINE_GRAPHICS), "{:?}", out.warnings); +} + +/// A gradient's pen is the average of its stops' colors. +#[test] +fn a_gradient_takes_the_average_of_its_stops() { + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + g.gradient.stops[0].color = Color::rgb8(255, 0, 0); + g.gradient.stops[1].color = Color::rgb8(0, 0, 255); + let d = + doc_with(|d| vec![Node::path(d.alloc_id(), rect(10.0, 10.0, 60.0, 40.0), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 1.0))]); + assert_eq!(plot(&d, &opts(&d)).pens, [[128, 0, 128]]); +} + +/// An object or group at 0% opacity shows nothing, and a fill or stroke at 0% opacity gives a +/// path no color: here only the blue fill's outline is drawn. +#[test] +fn art_at_no_opacity_is_left_out() { + let (red, green, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 255, 0), Color::rgb8(0, 0, 255)); + let d = doc_with(|d| { + let mut faded = stroked(d, rect(10.0, 10.0, 20.0, 20.0), red); + faded.opacity = 0.0; + let inner = stroked(d, line((30.0, 10.0), (40.0, 10.0)), green); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + group.opacity = 0.0; + let mut clear_fill = filled(d, rect(10.0, 30.0, 20.0, 40.0), green); + let mut fill_and_clear_stroke = + Node::path(d.alloc_id(), rect(50.0, 10.0, 60.0, 20.0), Appearance::basic(Paint::solid(blue), Paint::solid(red), 1.0)); + for item in clear_fill.appearance.items.iter_mut().chain(fill_and_clear_stroke.appearance.items.iter_mut()) { + match item { + AppearanceItem::Fill(f) if f.paint == Paint::solid(green) => f.opacity = 0.0, + AppearanceItem::Stroke(s) => s.opacity = 0.0, + _ => {} + } + } + vec![faded, group, clear_fill, fill_and_clear_stroke] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[0, 0, 255]]); + assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out)); +} + +/// A pattern's pen is the color of its first visible solid fill: a hidden tile object, one at 0% +/// opacity and a fill at 0% opacity are skipped. +#[test] +fn a_pattern_takes_the_color_of_its_first_visible_fill() { + let mut d = doc_with(|_| vec![]); + let mut hidden = filled(&mut d, rect(0.0, 0.0, 5.0, 5.0), Color::rgb8(255, 0, 0)); + hidden.visible = false; + let mut faded = filled(&mut d, rect(0.0, 5.0, 5.0, 10.0), Color::rgb8(0, 255, 0)); + faded.opacity = 0.0; + let mut clear_fill = filled(&mut d, rect(5.0, 5.0, 10.0, 10.0), Color::rgb8(255, 255, 0)); + for item in &mut clear_fill.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.opacity = 0.0; + } + } + let shown = filled(&mut d, rect(5.0, 0.0, 10.0, 5.0), Color::rgb8(0, 0, 255)); + let art = [hidden, faded, clear_fill, shown].map(Arc::new).to_vec(); + d.patterns.push(vectorcraft_doc::PatternDef::new("tiles", art)); + let path = Node::path( + d.alloc_id(), + rect(10.0, 10.0, 50.0, 50.0), + Appearance::basic(Paint::Pattern { pattern: "tiles".into(), xf: Default::default() }, Paint::None, 1.0), + ); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, path).unwrap(); + assert_eq!(plot(&d, &opts(&d)).pens, [[0, 0, 255]]); +} + +#[test] +fn art_is_cut_at_the_artboard_and_by_clipping_masks() { + let d = doc_with(|d| { + let across = stroked(d, line((-50.0, 50.0), (250.0, 50.0)), Color::BLACK); + let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::default()); + let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![across, group] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t, "IN;PU;SP1;PU0,706;PD2822,706;PU282,988;PD564,988;PU;SP0;"); +} + +#[test] +fn type_is_drawn_as_its_outlines() { + let d = doc_with(|d| { + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Ol", style))))] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TEXT_OUTLINES), "{:?}", out.warnings); + // A pen-up move to each contour (the O's two and the l's one), and the two around the pen. + let t = text(&out); + assert!(t.matches("PU").count() >= 5, "{t}"); +} + +#[test] +fn an_empty_or_zero_length_plot_has_no_moves() { + let empty = Document::new(200.0, 100.0); + assert_eq!(text(&plot(&empty, &opts(&empty))), "IN;PU;SP0;"); + let d = + doc_with(|d| vec![stroked(d, line((10.0, 10.0), (10.0, 10.0)), Color::BLACK), stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::BLACK)]); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); + let bad = HpglOptions { region: Rect::new(0.0, 0.0, 0.0, 10.0), ..HpglOptions::default() }; + assert_eq!(export(&d, &bad).err().as_deref(), Some("the export region is empty")); + let nan = HpglOptions { region: Rect::new(0.0, 0.0, f64::NAN, 10.0), ..HpglOptions::default() }; + assert!(export(&d, &nan).is_err()); +} + +/// A tolerance outside its range and a pen table of more than 255 pens are errors, not changed. +#[test] +fn bad_options_are_errors() { + let d = three(); + for tolerance in [0.0, 0.009, 10.01, f64::NAN, f64::INFINITY] { + let o = HpglOptions { tolerance, ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the curve tolerance must be from 0.01 to 10 points"), "{tolerance}"); + } + for tolerance in [0.01, 10.0] { + assert!(export(&d, &HpglOptions { tolerance, ..opts(&d) }).is_ok(), "{tolerance}"); + } + let o = HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS + 1], ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the pen table holds more than 255 pens")); + assert!(export(&d, &HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS], ..opts(&d) }).is_ok()); +} + +#[test] +fn art_past_the_plotter_range_is_cut_off() { + let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings); + assert_eq!(text(&out), format!("IN;PU;SP1;PU0,706;PD{MAX_COORD},706;PU;SP0;")); + // Art past the range but off the region asked for would be cut off anyway: no warning. + let d = doc_with(|d| vec![stroked(d, line((-1e9, 50.0), (10.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;"); +} + +/// HP-GL past 16-bit coordinates is written as it is, with a warning; HP-GL/2 gets none. +#[test] +fn hpgl_past_16_bit_coordinates_warns() { + let region = Rect::new(-2400.0, 0.0, 2400.0, 100.0); + // Past 32,767 from the bottom-left corner, within the range from the center. + let d = doc_with(|d| vec![stroked(d, line((-2000.0, 50.0), (2000.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU5644,706;PD62089,706;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU-28222,0;PD28222,0;PU;SP0;"); + // Past −32,768 and 32,767 from the center. + let d = doc_with(|d| vec![stroked(d, line((-2400.0, 50.0), (2400.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU-33867,0;PD33867,0;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, version: Version::Hpgl2, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn a_plot_stops_at_its_point_budget() { + let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect()); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + assert!(text(&out).matches("PU").count() <= MAX_POINTS / 5 + 3); +} + +/// The points a plain plot moves through. +fn moves(t: &str) -> usize { + t.split_terminator(';') + .filter(|i| i.starts_with("PU") || i.starts_with("PD")) + .map(|i| i[2..].split(',').filter(|v| !v.is_empty()).count() / 2) + .sum() +} + +#[test] +fn one_path_longer_than_the_budget_is_cut_at_it() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((1.0, 1.0)); + for i in 0..MAX_POINTS + 10 { + bp.line_to((if i % 2 == 0 { 2.0 } else { 1.0 }, 1.0 + (i % 50) as f64)); + } + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + let n = moves(&text(&out)); + assert!(n > 2 && n <= MAX_POINTS, "{n}"); +} + +/// A path whose flattening stops at the budget while its moves so far lie off the artboard: the +/// rest, over the artboard, is left out with a warning. +#[test] +fn a_path_cut_short_by_the_budget_warns() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((-50.0, 10.0)); + for i in 0..MAX_POINTS * 4 + 10 { + bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0)); + } + bp.line_to((50.0, 50.0)); + bp.line_to((60.0, 50.0)); + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); +} + +/// A filled path cut short by the budget is not closed: the move from where it stopped back to its +/// start is not on its outline. Only its first move, along y = 50 off the artboard's left edge, +/// is drawn. +#[test] +fn a_filled_path_cut_short_stays_open() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((10.0, 50.0)); + bp.line_to((-40.0, 50.0)); + for i in 0..MAX_POINTS * 4 + 10 { + bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0)); + } + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD0,706;PU;SP0;"); +} + +/// A blend's steps that its bent spine turns onto the region plotted are drawn, although its keys +/// and spine lie off it. +#[test] +fn blend_steps_a_spine_turns_onto_the_region_are_drawn() { + let d = doc_with(|d| { + let keys = + vec![stroked(d, line((-400.0, -200.0), (200.0, -200.0)), Color::BLACK), stroked(d, line((-560.0, -60.0), (40.0, -60.0)), Color::BLACK)]; + let mut spine = BezPath::new(); + spine.move_to((-100.0, -200.0)); + spine.quad_to((-100.0, -60.0), (-260.0, -60.0)); + let spec = vectorcraft_doc::BlendSpec { + spacing: vectorcraft_doc::BlendSpacing::Steps(20), + spine: Some(PathData::from_bezpath(&spine)), + orientation: vectorcraft_doc::BlendOrientation::AlignToPath, + ..Default::default() + }; + vec![Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) })] + }); + // The keys and spine lie above y = -55; the steps turned along the spine cross this area. + let region = Rect::new(-300.0, 0.0, -200.0, 50.0); + let t = text(&plot(&d, &HpglOptions { region, ..HpglOptions::default() })); + assert!(t.contains("PD"), "{t}"); +} + +/// A closed path that starts on the artboard, leaves it and comes back is one line, with no +/// pen lift where it started. +#[test] +fn a_closed_path_cut_at_the_artboard_is_one_line() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((150.0, 10.0)); + bp.line_to((250.0, 10.0)); + bp.line_to((250.0, 50.0)); + bp.line_to((150.0, 50.0)); + bp.close_path(); + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t, "IN;PU;SP1;PU2822,706;PD2117,706,2117,1270,2822,1270;PU;SP0;"); +} + +/// A clipping path that paints is drawn once: where its fill paints (before the art it clips), +/// or where its stroke paints (after it). +#[test] +fn a_clipping_path_is_drawn_where_it_paints() { + let clip_doc = |fill: Paint, stroke: Paint| { + doc_with(|d| { + let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(fill, stroke, 1.0)); + let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let red = Paint::solid(Color::rgb8(255, 0, 0)); + let filled = clip_doc(red.clone(), Paint::None); + assert_eq!(plot(&filled, &opts(&filled)).pens, [[255, 0, 0], [0, 0, 0]]); + let stroked = clip_doc(Paint::None, red.clone()); + assert_eq!(plot(&stroked, &opts(&stroked)).pens, [[0, 0, 0], [255, 0, 0]]); + let both = clip_doc(Paint::solid(Color::rgb8(0, 0, 255)), red); + let out = plot(&both, &opts(&both)); + assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]], "drawn once, in its stroke's color"); + // Two pen changes, a move to each of the two paths, and the last pen up. + assert_eq!(text(&out).matches("PU").count(), 5, "{}", text(&out)); + // A stroke at 0% opacity isn't drawn: the clipping path is drawn in its fill, below. + let faded = doc_with(|d| { + let mut clip = Node::path( + d.alloc_id(), + rect(20.0, 20.0, 40.0, 40.0), + Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0), + ); + for item in &mut clip.appearance.items { + if let AppearanceItem::Stroke(s) = item { + s.opacity = 0.0; + } + } + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]); + // An open clipping path with a fill and a stroke is drawn closed, as other filled paths are. + let mut open = BezPath::new(); + open.move_to((20.0, 20.0)); + open.line_to((40.0, 20.0)); + open.line_to((40.0, 40.0)); + let open = PathData::from_bezpath(&open); + let open_doc = doc_with(|d| { + let clip = Node::path(d.alloc_id(), open, Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0)); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + let t = text(&plot(&open_doc, &opts(&open_doc))); + assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}"); +} + +/// A symbol `art` and an instance of it at its place. +fn symbol_doc(art: Node, instance: impl FnOnce(&mut Node)) -> Document { + let mut d = doc_with(|d| { + let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + instance(&mut n); + vec![n] + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art) }); + d +} + +/// Symbols nested more than 8 deep are left out with a warning. +#[test] +fn symbols_nested_too_deep_are_left_out_with_a_warning() { + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s9".into(), xf: Affine::IDENTITY })]); + let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s0".into(), art: Arc::new(art) }); + for i in 1..10 { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("s{}", i - 1), xf: Affine::IDENTITY }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("s{i}"), art: Arc::new(inner) }); + } + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_DEEP), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// A symbol instance stained by its fill draws in the stained color. +#[test] +fn a_stained_symbol_instance_takes_its_stain() { + let mut d = Document::new(200.0, 100.0); + let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + let d = symbol_doc(art, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 1.0).into()); + assert_eq!(plot(&d, &opts(&d)).pens, [[255, 0, 0]]); +} + +/// The geometry effects in a symbol's art apply: a line moved 30 pt right by a Transform effect. +#[test] +fn a_symbol_draws_its_art_with_its_effects() { + let mut d = Document::new(200.0, 100.0); + let mut art = stroked(&mut d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 30.0}), + visible: true, + }); + let d = symbol_doc(art, |_| {}); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP1;PU564,1270;PD706,1270;PU;SP0;"); +} + +/// Art an effect moves onto the artboard from off it is drawn, for an instance on its own and +/// one in a group: the bounds that skip art off the artboard don't measure symbol effects. +#[test] +fn symbol_art_an_effect_moves_onto_the_artboard_is_drawn() { + let mut d = Document::new(200.0, 100.0); + let mut art = stroked(&mut d, line((-30.0, 10.0), (-20.0, 10.0)), Color::BLACK); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 40.0}), + visible: true, + }); + let alone = symbol_doc(art.clone(), |_| {}); + assert_eq!(text(&plot(&alone, &opts(&alone))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + let mut grouped = doc_with(|d| { + let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + vec![Node::group(d.alloc_id(), vec![Arc::new(n)])] + }); + grouped.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art.clone()) }); + assert_eq!(text(&plot(&grouped, &opts(&grouped))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + // Other art off the artboard is still skipped: a blend there doesn't spend the live art's + // limit (1,000 objects in tests) before the blend on the artboard. + let mut d = doc_with(|d| { + let off = + vec![stroked(d, line((-500.0, 10.0), (-500.0, 20.0)), Color::BLACK), stroked(d, line((-400.0, 10.0), (-400.0, 20.0)), Color::BLACK)]; + let on = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((30.0, 10.0), (30.0, 20.0)), Color::BLACK)]; + vec![blend_of(d, off, 1_200), blend_of(d, on, 1)] + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "unused".into(), art: Arc::new(art) }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 3, "{}", text(&out)); +} + +/// A rectangle with `per_side` straight moves along each side. +fn fine_rect(x0: f64, y0: f64, x1: f64, y1: f64, per_side: u32) -> PathData { + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]; + let mut bp = BezPath::new(); + bp.move_to(corners[0]); + for (k, a) in corners.iter().enumerate() { + let b = corners[(k + 1) % 4]; + for i in 1..=per_side { + let t = f64::from(i) / f64::from(per_side); + bp.line_to((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); + } + } + bp.close_path(); + PathData::from_bezpath(&bp) +} + +/// Nested clipping masks of 6,000 points each pass the 10,000 points masks may hold together +/// in tests: the inner one is left out with a warning, and the line is cut by the outer one only. +#[test] +fn masks_past_their_point_budget_are_left_out() { + let d = doc_with(|d| { + let clip_group = |d: &mut Document, mask: PathData, art: Node| { + let mask = Node::path(d.alloc_id(), mask, Appearance::basic(Paint::None, Paint::None, 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + group + }; + let across = Node::path(d.alloc_id(), line((0.0, 50.0), (200.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let inner = clip_group(d, fine_rect(50.0, 30.0, 150.0, 70.0, 1500), across); + vec![clip_group(d, fine_rect(10.0, 10.0, 190.0, 90.0, 1500), inner)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == MASK_POINTS), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD2681,706;PU;SP0;"); +} + +/// A line through the 500 teeth of a comb-shaped mask crosses its edges about 1,000 times, and +/// each piece between crossings is tested against all 2,002 edges: that spends the clipping +/// budget, so the line is drawn whole with a warning. +#[test] +fn a_mask_crossed_many_times_spends_the_clipping_budget() { + let d = doc_with(|d| { + let mut pts = vec![]; + for i in 0..500 { + let x = f64::from(i) * 0.4; + pts.extend([(x, 20.0), (x + 0.2, 20.0), (x + 0.2, 50.0), (x + 0.4, 50.0)]); + } + pts.extend([(200.0, 80.0), (0.0, 80.0)]); + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + pts[1..].iter().for_each(|&p| bp.line_to(p)); + bp.close_path(); + let comb = Node::path(d.alloc_id(), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::None, 1.0)); + let clipped = stroked(d, line((0.5, 35.0), (199.5, 35.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(comb), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == CLIP_WORK), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out)); +} + +#[test] +fn more_colors_than_pens_share_the_nearest_pen() { + let d = doc_with(|d| (0..300).map(|i| stroked(d, rect(1.0, 1.0, 5.0, 5.0), Color::rgb8((i % 256) as u8, (i / 256) as u8, 7))).collect()); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens.len(), MAX_PENS); + assert!(out.warnings.iter().any(|w| w == MANY_COLORS), "{:?}", out.warnings); +} + +#[test] +fn sorting_draws_the_nearest_path_next() { + let d = doc_with(|d| { + vec![ + stroked(d, line((150.0, 90.0), (150.0, 80.0)), Color::BLACK), + stroked(d, line((10.0, 80.0), (10.0, 90.0)), Color::BLACK), + stroked(d, line((80.0, 90.0), (80.0, 80.0)), Color::BLACK), + ] + }); + let unsorted = text(&plot(&d, &opts(&d))); + assert_eq!(unsorted, "IN;PU;SP1;PU2117,141;PD2117,282;PU141,282;PD141,141;PU1129,141;PD1129,282;PU;SP0;"); + // From the origin: the left line drawn backwards from its lower end, then the middle and the + // right one from the end the pen is nearest. + let sorted = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })); + assert_eq!(sorted, "IN;PU;SP1;PU141,141;PD141,282;PU1129,282;PD1129,141;PU2117,141;PD2117,282;PU;SP0;"); +} + +/// A closed path is as near as its nearest point, and starts there. +#[test] +fn sorting_measures_a_closed_path_by_its_nearest_point() { + let d = doc_with(|d| vec![stroked(d, rect(40.0, 60.0, 50.0, 70.0), Color::BLACK), stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK)]); + let t = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })); + // The large square's bottom-left corner is nearer the origin than any point of the small one. + assert_eq!(t, "IN;PU;SP1;PU141,141;PD141,1270,2681,1270,2681,141,141,141;PU564,423;PD564,564,706,564,706,423,564,423;PU;SP0;"); +} + +#[test] +fn sorting_stops_at_its_budget_and_keeps_every_path() { + let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); + assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 600); +} + +/// `x` points in plotter units. +fn plu(x: f64) -> i64 { + (x * 1016.0 / 72.0).round() as i64 +} + +/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units). +fn lefts(out: &HpglOutput) -> Vec { + let mut lefts: Vec = vec![]; + for i in text(out).split_terminator(';') { + let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::().ok()); + match i.get(..2) { + Some("PU") => lefts.extend(xs.min()), + Some("PD") => { + if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) { + *l = (*l).min(x); + } + } + _ => {} + } + } + lefts +} + +/// A letter O made as one path: its outline, then its counter. +#[test] +fn inside_first_cuts_a_letters_counter_before_its_outline() { + let d = doc_with(|d| { + let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath(); + bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath()); + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]); +} + +/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`. +#[test] +fn a_part_with_two_holes_is_cut_holes_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK), + ] + }); + let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0)); + assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]); + // Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest + // hole comes first, then the other, then the part. + assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]); + let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&both), [left, right, part]); + assert!(both.warnings.is_empty(), "{:?}", both.warnings); +} + +/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place +/// in paint order. +#[test] +fn nested_rings_are_cut_innermost_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK), + stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]); +} + +/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with +/// the red pen, after the black part. +#[test] +fn inside_first_orders_each_pens_paths() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)), + stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]); +} + +/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in +/// paint order, with a warning. +#[test] +fn inside_first_past_its_budget_draws_in_paint_order() { + let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED]); + let l = lefts(&out); + assert_eq!(l.len(), 100); + assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); + // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(lefts(&out), l); +} + +/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and +/// then the outer one last. +#[test] +fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)]; + v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNSORTED_INSIDE]); + let l = lefts(&out); + assert_eq!(l.len(), 602); + assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); +} + +/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them, +/// within the budget. +#[test] +fn inside_first_alone_keeps_paint_order_for_many_paths() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)]; + v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let l = lefts(&out); + assert_eq!(l.len(), 1001); + assert_eq!(l.last(), Some(&plu(0.5))); + assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}"); +} + +#[test] +fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() { + let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// The reference guide's example of an encoded number: 10,525 is written as the bytes 121, 71 +/// and 196 (https://support.hp.com/sg-en/document/c01121263). +#[test] +fn encoded_numbers_follow_the_reference_guide() { + let mut out = vec![]; + pe_number(&mut out, 10_525); + assert_eq!(out, [121, 71, 196]); + out.clear(); + pe_number(&mut out, -1); + pe_number(&mut out, 0); + // The sign in the low bit: -1 is 3. + assert_eq!(out, [194, 191]); +} + +#[test] +fn hpgl2_writes_the_palette_and_encoded_polylines() { + let out = plot(&three(), &HpglOptions { version: Version::Hpgl2, ..opts(&three()) }); + let b = &out.bytes; + assert!(b.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;PC3,0,0,255;PU;SP1;PE<="), "{:?}", String::from_utf8_lossy(b)); + assert!(b.ends_with(b";PU;SP0;")); + assert_eq!(b.windows(4).filter(|w| w == b";PE<").count(), 3, "one encoded polyline per pen"); + // Encoded moves take fewer bytes than the same moves written as text. + let plain = plot(&three(), &opts(&three())); + assert!(b.len() < plain.bytes.len(), "{} against {}", b.len(), plain.bytes.len()); +} + +/// A move longer than 8,388,607 units is written absolute (`=`), so every number stays in the +/// range: here from about −5,000,000 to +5,000,000 units around a centered origin. +#[test] +fn hpgl2_writes_a_move_past_the_range_absolute() { + let d = doc_with(|d| { + vec![ + stroked(d, line((-354_000.0, 50.0), (-353_990.0, 50.0)), Color::BLACK), + stroked(d, line((353_990.0, 50.0), (354_000.0, 50.0)), Color::BLACK), + ] + }); + let o = HpglOptions { + version: Version::Hpgl2, + origin: Origin::Center, + region: Rect::new(-400_000.0, 0.0, 400_000.0, 100.0), + ..HpglOptions::default() + }; + let out = plot(&d, &o); + assert_eq!(out.bytes.iter().filter(|b| **b == b'=').count(), 2, "{:?}", String::from_utf8_lossy(&out.bytes)); + let back = super::super::import(&out.bytes).unwrap(); + let mut xs = vec![]; + back.document.walk(|n| { + if let Some(p) = n.path_data() { + xs.extend(p.to_bezpath().elements().iter().filter_map(|e| if let PathEl::MoveTo(p) = e { Some(p.x) } else { None })); + } + }); + assert_eq!(xs.len(), 2); + assert!((xs[1] - xs[0] - 707_990.0).abs() < 0.2, "{xs:?}"); +} + +/// The points of every path `doc` holds, in paint order. +fn polylines(doc: &Document) -> Vec<(Vec, Option)> { + let mut out = vec![]; + doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + let bp = path.to_bezpath(); + let pts = bp.elements().iter().filter_map(|e| match e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => Some(*p), + _ => None, + }); + let stroke = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.paint.clone()) } else { None }); + out.push((pts.collect(), stroke)); + } + }); + out +} + +/// Each version's plot read back: the same lines, within the tolerance and half a plotter unit, +/// and with HP-GL/2 the same colors. +#[test] +fn plots_read_back_as_the_same_lines() { + let d = three(); + for version in Version::ALL { + let out = plot(&d, &HpglOptions { version, ..opts(&d) }); + let back = super::super::import(&out.bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let lines = polylines(&back.document); + assert_eq!(lines.len(), 3, "{version:?}"); + // The reader's artboard is round what the art paints: line the two up by the square. + let shift = lines[0].0[0] - Point::new(10.0, 10.0); + // Two roundings to whole plotter units (1/1016 in) apart at most. + let near = |p: Point, q: Point| (p - shift).distance(q) <= 0.11; + let square = [(10.0, 10.0), (60.0, 10.0), (60.0, 40.0), (10.0, 40.0), (10.0, 10.0)].map(Point::from); + assert!(lines[0].0.iter().zip(square).all(|(p, q)| near(*p, q)) && lines[0].0.len() == 5, "{:?}", lines[0].0); + assert!(near(lines[1].0[0], Point::new(70.0, 20.0)) && near(lines[1].0[1], Point::new(150.0, 80.0)), "{:?}", lines[1].0); + // The ellipse's every point lies on it, within the tolerance and the rounding. + let ellipse = kurbo::Ellipse::new((100.0, 50.0), (30.0, 20.0), 0.0); + for p in &lines[2].0 { + let q = p.to_vec2() - shift - kurbo::Vec2::new(100.0, 50.0); + let r = (q.x / 30.0).hypot(q.y / 20.0); + assert!((r - 1.0).abs() * 20.0 <= DEFAULT_TOLERANCE + 0.1, "{p:?} off the ellipse"); + } + assert!(lines[2].0.len() > 16 && ellipse.area() > 0.0); + if version == Version::Hpgl2 { + let colors: Vec> = lines.iter().map(|l| l.1.clone()).collect(); + assert_eq!( + colors, + [Paint::solid(Color::rgb8(0, 0, 0)), Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::rgb8(0, 0, 255))].map(Some) + ); + } + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 525e9b6734..0ef28a4aaa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,5 +1,5 @@ //! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an -//! HP-GL/2 plot file reader. +//! HP-GL/2 plot file reader and writer. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -12,13 +12,13 @@ //! clipping) is approximated or left out, and each such loss comes back as a warning. //! //! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. -//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`]. +//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. //! - `writer`: group codes, handles and the file's sections, tables and objects. //! - `scene`: the document walk that turns objects into entities. //! - `import`: the reader, entities to art, blocks to symbols. -//! - `hpgl`: the HP-GL reader, the pen's moves to paths. +//! - `hpgl`: the HP-GL reader, the pen's moves to paths, and the writer, paths to pen moves. mod aci; pub mod hpgl; @@ -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..5fc952a061 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,8 @@ 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!("holographic", "Holographic", holographic), lib!("duotone-gradients", "Duotone Gradients", duotone_gradients), lib!("radial-glows", "Radial Glows", radial_glows), ]; @@ -179,6 +181,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 +397,197 @@ 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 stops of a [`holographic`] look: offset, opacity and midpoint toward the next stop. +type Bands = [(f32, f32, f32)]; + +/// Foil: six opaque linear bands, a narrow bright one among them, with off-center midpoints. +const FOIL: &Bands = &[(0.0, 1.0, 0.37), (0.13, 1.0, 0.64), (0.34, 1.0, 0.28), (0.48, 1.0, 0.68), (0.71, 1.0, 0.42), (1.0, 1.0, 0.5)]; + +/// Glow: five radial rings from a bright center out to a clear edge. +const GLOW: &Bands = &[(0.0, 0.95, 0.4), (0.12, 1.0, 0.58), (0.31, 0.72, 0.34), (0.58, 0.3, 0.62), (1.0, 0.0, 0.5)]; + +/// A [`holographic`] look: name, kind, stops and the OKLCH color of each stop in order. +type Look = (&'static str, GradientKind, &'static Bands, Anchors); + +/// Iridescent looks mixed perceptually: linear foils of narrow pastel and pearl bands, and radial +/// glows that fade out to a clear edge. Each color is one stop of [`FOIL`] or [`GLOW`] in order. +fn holographic() -> (Vec, Vec) { + use GradientKind::{Linear, Radial}; + // Pale pearl, pastel and deep-violet OKLCH `(l, c, h°)` anchors. + const PEARL: (f64, f64, f64) = (0.97, 0.01, 90.0); + const SILVER: (f64, f64, f64) = (0.86, 0.015, 255.0); + const BLUSH: (f64, f64, f64) = (0.82, 0.055, 340.0); + const VIOLET: (f64, f64, f64) = (0.72, 0.1, 310.0); + const BLUE: (f64, f64, f64) = (0.73, 0.09, 255.0); + const CYAN: (f64, f64, f64) = (0.79, 0.09, 210.0); + const MINT: (f64, f64, f64) = (0.82, 0.08, 165.0); + const LIME: (f64, f64, f64) = (0.86, 0.085, 125.0); + const GOLD: (f64, f64, f64) = (0.89, 0.08, 85.0); + const AMBER: (f64, f64, f64) = (0.82, 0.1, 55.0); + const CORAL: (f64, f64, f64) = (0.78, 0.09, 25.0); + const DEEP_VIOLET: (f64, f64, f64) = (0.42, 0.04, 275.0); + let looks: [Look; 14] = [ + ("Prism Veil", Linear, FOIL, &[SILVER, BLUSH, PEARL, CYAN, VIOLET, SILVER]), + ("Opal Ribbon", Linear, FOIL, &[PEARL, MINT, BLUSH, PEARL, BLUE, SILVER]), + ("Pearl Flash", Linear, FOIL, &[SILVER, VIOLET, PEARL, GOLD, CYAN, PEARL]), + ("Aurora Foil", Linear, FOIL, &[BLUE, MINT, PEARL, BLUSH, CYAN, SILVER]), + ("Iris Sheen", Linear, FOIL, &[BLUSH, VIOLET, PEARL, CYAN, MINT, SILVER]), + ("Frost Spectrum", Linear, FOIL, &[CYAN, BLUE, PEARL, MINT, BLUSH, SILVER]), + ("Silver Tide", Linear, FOIL, &[SILVER, CYAN, PEARL, VIOLET, BLUE, SILVER]), + ("Rose Quartz Gleam", Radial, GLOW, &[PEARL, BLUSH, CORAL, VIOLET, BLUSH]), + ("Moonstone Shift", Radial, GLOW, &[PEARL, CYAN, MINT, BLUE, SILVER]), + ("Violet Edge", Radial, GLOW, &[PEARL, VIOLET, BLUSH, BLUE, DEEP_VIOLET]), + ("Mint Halo", Radial, GLOW, &[PEARL, MINT, CYAN, LIME, MINT]), + ("Amber Corona", Radial, GLOW, &[PEARL, GOLD, AMBER, CORAL, AMBER]), + ("Polar Glint", Radial, GLOW, &[PEARL, CYAN, BLUE, VIOLET, BLUE]), + ("Pastel Orbit", Radial, GLOW, &[PEARL, BLUSH, MINT, CYAN, VIOLET]), + ]; + let s = looks + .iter() + .map(|&(name, kind, bands, colors)| { + let stops: Vec<(f32, Color, f32, f32)> = + bands.iter().zip(colors).map(|(&(at, opacity, mid), &(l, c, h))| (at, oklch(l, c, h), opacity, mid)).collect(); + gradient_with(name, kind, GradientMethod::Perceptual, &stops) + }) + .collect(); + (s, vec![]) +} + /// The sequential scales of [`perceptual`]: name and anchors of rising lightness. const SEQUENTIAL: [(&str, Anchors); 5] = [ ("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]), @@ -532,6 +733,99 @@ 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 holographic_holds_seven_opaque_foils_and_seven_glows_to_a_clear_edge() { + let lib = builtin_library("holographic").unwrap(); + let order: Vec<&str> = GRADIENT_LIBRARIES.iter().map(|b| b.name).collect(); + assert_eq!(order.iter().position(|n| *n == "Holographic"), Some(4), "after Organic Fades: {order:?}"); + assert!(lib.groups.is_empty()); + assert_eq!(lib.len(), 14); + // No name another built-in gradient has, in any case. + let mut others = std::collections::HashSet::new(); + for other in builtins().filter(|b| b.id != "holographic").map(|b| builtin_library(b.id).unwrap()) { + others.extend(other.iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| s.name.to_lowercase())); + } + assert!(lib.iter().all(|s| !others.contains(&s.name.to_lowercase())), "a name another library has"); + let (mut foils, mut glows) = (0, 0); + for s in lib.iter() { + let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) }; + let (g, n) = (&g.gradient, &s.name); + assert_eq!(g.method, GradientMethod::Perceptual, "{n}"); + let bands = match &g.kind { + GradientKind::Linear => { + foils += 1; + FOIL + } + GradientKind::Radial => { + glows += 1; + GLOW + } + kind => panic!("{n}: {kind:?}"), + }; + // Every stop takes its band's offset, opacity and midpoint, and none is dropped. + let shape: Vec<(f32, f32, f32)> = g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect(); + assert_eq!(shape, bands, "{n}"); + assert!(g.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{n}"); + } + assert_eq!((foils, glows), (7, 7)); + // Foils are opaque; a glow is clear at the edge. + assert!(FOIL.iter().all(|&(_, opacity, _)| opacity == 1.0)); + assert_eq!(GLOW.last().map(|b| b.1), Some(0.0)); + // Every segment's midpoint is off center and within the Gradient panel's 13 to 87%. + for bands in [FOIL, GLOW] { + assert!(bands[..bands.len() - 1].iter().all(|&(_, _, m)| (m - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&m))); + assert!(bands.windows(2).all(|w| w[0].0 < w[1].0) && bands[0].0 == 0.0 && bands[bands.len() - 1].0 == 1.0); + } + // Sampled colors: Prism Veil's bright band is the pearl anchor, and Violet Edge ends in deep violet. + let stop = |name: &str, i: usize| match &lib.swatch(name).unwrap().paint { + Paint::Gradient(g) => g.gradient.stops[i].color, + _ => panic!("{name} is a gradient"), + }; + assert_eq!(stop("Prism Veil", 2), oklch(0.97, 0.01, 90.0)); + assert_eq!(stop("Violet Edge", 4), oklch(0.42, 0.04, 275.0)); + let [l, ..] = to_oklab(&stop("Violet Edge", 4)); + assert!(l < 0.5 && to_oklab(&stop("Prism Veil", 2))[0] > 0.95, "{l}"); + } + + #[test] + fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() { + for b in builtins().filter(|b| !["organic-fades", "holographic"].contains(&b.id)) { + for s in builtin_library(b.id).unwrap().iter() { + if let Paint::Gradient(g) = &s.paint { + assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name); + 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..4e51777060 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1051,17 +1051,51 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { /// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread -/// evenly along it by arc length). +/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as +/// above at the anchors where they were spread. +/// +/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc], 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()))); }; let mut first = stored.subpaths.first()?.clone(); - let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) }; + let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { + let mut path = PathData::single(first); + // A key moved off where Replace Spine spread it takes its point of the spine along, as + // on a pinned spine; otherwise the steps would put it back on the spine (#6809). + if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) { + return Some((path, None)); + } + let Some(mut ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) }; + let mut first = path.subpaths.into_iter().next()?; + open_at_start(&mut first, &mut ka); + for (k, &ai) in keys.iter().zip(&ka) { + if let Some(a) = first.anchors.get_mut(ai) { + let d = center_of(k) - a.p; + a.translate(d); + } + } + return Some((PathData::single(first), Some(ka))); + }; for (k, &ai) in keys.iter().zip(&ka) { let c = center_of(k); if let Some(a) = first.anchors.get_mut(ai) { @@ -1075,21 +1109,62 @@ pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Op /// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its /// keys sit (by arc length), so editing it keeps them in place. pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec)> { - let (mut path, anchors) = blend_spine(keys, spec)?; - if let Some(a) = anchors { - return Some((path, a)); + // A spine from Replace Spine is pinned as stored, even with a key moved off it (where + // [`blend_spine`] moves that key's point along): the layout is the same. + if keys.len() >= 2 + && let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first()) + && pinned(spec, keys.len(), first.anchors.len()).is_none() + { + let mut path = PathData::single(first.clone()); + let mut out = spread_anchors(&mut path, keys.len())?; + // A closed spine's end keys get points of their own, so that once pinned each still + // moves only its own end. + let mut sp = path.subpaths.into_iter().next()?; + open_at_start(&mut sp, &mut out); + return Some((PathData::single(sp), out)); } + let (path, anchors) = blend_spine(keys, spec)?; + Some((path, anchors?)) +} + +/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length +/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)? +fn keys_spread_along(keys: &[Arc], spine: &Spine) -> bool { let k = keys.len(); - let total = Spine::new(&path)?.length(); + keys.iter().enumerate().all(|(i, key)| { + let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; + center_of(key).distance(spine.at(f).0) < 0.01 + }) +} + +/// Open closed spine `sp` at its first anchor when the last key sits there with the first (as +/// [`spread_anchors`] puts them): a copy of that anchor at the end takes the closing segment and +/// the last key, so the two end keys move their ends of the spine apart. +fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) { + let Some(&start) = sp.anchors.first() else { return }; + if !sp.closed || ka.len() < 2 { + return; + } + if let Some(last) = ka.last_mut().filter(|a| **a == 0) { + *last = sp.anchors.len(); + sp.anchors.push(start); + sp.closed = false; + } +} + +/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit +/// on, splitting segments where none is. +fn spread_anchors(path: &mut PathData, k: usize) -> Option> { + let total = Spine::new(path)?.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); let mut out = vec![0usize]; for i in 1..k.saturating_sub(1) { let s = total * i as f64 / (k - 1) as f64; - out.push(anchor_at_length(&mut path, s)?); + out.push(anchor_at_length(path, s)?); } let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); out.push(last); - Some((path, out)) + Some(out) } /// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a @@ -1127,10 +1202,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 +1267,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 +1479,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/lib.rs b/crates/doc/src/lib.rs index 41a684abde..2c5759f202 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -267,7 +267,7 @@ pub struct Document { /// they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, - /// File → File Info (the title is [`Document::title`]). + /// File → File Info (its title is [`Document::info_title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, /// Effect → Document Raster Effects Settings besides the resolution diff --git a/crates/doc/src/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/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 151365c5d2..908b649e4d 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -1,11 +1,15 @@ //! File → File Info: the document's descriptive metadata (author, description, keywords, rating, //! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The -//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved -//! before File Info existed load unchanged. +//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it +//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and +//! [`Document::info_title`] is the title File Info shows and exports write. Every field is +//! defaulted, so documents saved before File Info existed load unchanged. use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; +use crate::Document; + /// File Info → Copyright Status. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DocMetadata { + /// The title the file this document was opened from gives itself (an SVG's root ``, a + /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of + /// [`Document::title`], the document's display name. `None`: the file gave none, so the + /// display name is the title. Left out of a saved document when unset, so other documents + /// save as they did. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, pub author: String, /// The author's position or job title. pub author_title: String, @@ -83,6 +94,16 @@ impl DocMetadata { } } +impl Document { + /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP + /// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from + /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the + /// default file name) is always [`Document::title`]. + pub fn info_title(&self) -> &str { + self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title) + } +} + /// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time` /// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock, /// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same @@ -191,6 +212,24 @@ mod tests { assert!(DocMetadata::default().png_text("").is_empty()); } + #[test] + fn the_info_title_is_the_files_own_title_else_the_display_name() { + let mut d = Document::new(10.0, 10.0); + d.title = "box.svg".into(); + assert_eq!(d.info_title(), "box.svg"); + d.metadata.title = Some("Access paths".into()); + assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths")); + d.metadata.title = Some(" ".into()); + assert_eq!(d.info_title(), "box.svg"); + // Saved only when set: other documents' metadata is as before, and the field comes back. + d.metadata.title = None; + assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title")); + d.metadata.title = Some("Access paths".into()); + let json = serde_json::to_string(&d.metadata).unwrap(); + assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths")); + assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None); + } + /// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative /// decimal integer; anything else leaves the clock to decide, so an export is never refused. #[test] diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 97d46b7cda..4ecb8948bb 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -156,6 +156,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures }, + BuiltinStyleLibrary { id: "painted-sketched", name: "Painted and Sketched", make: painted_sketched }, BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, @@ -323,6 +324,59 @@ fn hand_drawn() -> Vec<GraphicStyle> { ] } +/// Painted and Sketched: one catalog paint or sketch effect over the whole appearance, so it +/// repaints the fill and the stroke together (watercolor, dry brush, pastels, charcoal and ink). +fn painted_sketched() -> Vec<GraphicStyle> { + vec![ + style( + "Washed Blue", + vec![F(fill("#8ab9c9"))], + vec![fx("artistic.watercolor", json!({"brushDetail": 8.0, "shadowIntensity": 2.0, "texture": 2.0}))], + ), + style( + "Bristle Ochre", + vec![F(fill("#c4975a")), S(stroke("#76533d", 1.0))], + vec![fx("artistic.dryBrush", json!({"brushSize": 4.0, "brushDetail": 7.0, "texture": 2.0}))], + ), + style( + "Pastel Heather", + vec![F(fill("#a78ca5"))], + vec![fx( + "artistic.roughPastels", + json!({ + "strokeLength": 8.0, + "strokeDetail": 9.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 5.0, + "lightDirection": "topLeft", + "invert": false + }), + )], + ), + style( + "Charcoal Shade", + vec![F(fill("#666666"))], + vec![fx("sketch.charcoal", json!({"thickness": 3.0, "detail": 3.0, "lightDarkBalance": 55.0}))], + ), + style( + "Ink Contour", + vec![F(fill("#d9e2df")), S(stroke("#26323b", 1.0))], + vec![fx("brushStrokes.inkOutlines", json!({"strokeLength": 6.0, "darkIntensity": 24.0, "lightIntensity": 8.0}))], + ), + style( + "Wet Ink", + vec![F(fill("#637b85"))], + vec![fx("brushStrokes.sumiE", json!({"strokeWidth": 7.0, "strokePressure": 5.0, "contrast": 20.0}))], + ), + style( + "Fine Pen", + vec![F(fill("#d8d4c6"))], + vec![fx("sketch.graphicPen", json!({"strokeLength": 6.0, "lightDarkBalance": 55.0, "strokeDirection": "rightDiagonal"}))], + ), + ] +} + /// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and /// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper). fn textures() -> Vec<GraphicStyle> { @@ -603,6 +657,36 @@ mod tests { assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } + #[test] + fn painted_and_sketched_follows_textures_with_one_effect_per_style() { + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + let at = ids.iter().position(|&id| id == "painted-sketched").expect("listed"); + assert_eq!(ids[at - 1], "textures"); + let lib = builtin_style_library("painted-sketched").unwrap(); + assert_eq!(lib.name, "Painted and Sketched"); + let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Washed Blue", "Bristle Ochre", "Pastel Heather", "Charcoal Shade", "Ink Contour", "Wet Ink", "Fine Pen"]); + let effects: Vec<&str> = lib.styles.iter().flat_map(|g| g.appearance.effects.iter().map(|e| e.id.as_str())).collect(); + assert_eq!( + effects, + [ + "artistic.watercolor", + "artistic.dryBrush", + "artistic.roughPastels", + "sketch.charcoal", + "brushStrokes.inkOutlines", + "brushStrokes.sumiE", + "sketch.graphicPen" + ] + ); + assert!(lib.styles.iter().all(|g| g.appearance.items.iter().all(|i| i.effects().is_empty())), "no effects on a fill or stroke"); + // No other built-in library holds a style of the same name. + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "painted-sketched") { + let other = builtin_style_library(b.id).unwrap(); + assert!(other.styles.iter().all(|g| !names.contains(&g.name.as_str())), "{}", b.id); + } + } + #[test] fn vcstyles_round_trip() { let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone(); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 05f9c29041..da845ba739 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -52,7 +52,7 @@ pub struct TextObject { /// the object, so the runs (and their styles) stay the text itself. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ruby: Vec<RubySpan>, - #[serde(default)] + #[serde(default = "ParaStyle::omitted")] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index fd828c79af..37c76c5e3a 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -28,6 +28,12 @@ pub struct CharStyle { /// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option<f64>, + /// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by + /// itself: the first character of the text, of a paragraph, or after a forced line break. The + /// Type tool's caret before such a character kerns with it (#5111), since there is no character + /// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect. + #[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")] + pub kerning_before: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -411,6 +417,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f23de1d41f..bb3725072e 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -244,9 +244,21 @@ pub struct ParaStyle { /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with /// kashidas before they widen their word spaces. pub kashida: Kashida, + /// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul + /// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul) + /// breaks only at its spaces and punctuation, or where it is wider than the line. New type and + /// imported text have it off; paragraphs saved before it existed open with it on, the line + /// breaks they were laid out with. + pub korean_line_break_anywhere: bool, } impl ParaStyle { + /// The attributes a text object saved without `para` reads with: the defaults, with Korean + /// Line Break Anywhere on, as a saved paragraph without that field reads. + pub(crate) fn omitted() -> Self { + Self { korean_line_break_anywhere: true, ..Self::default() } + } + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is @@ -330,6 +342,10 @@ struct ParaStyleFile { roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, + /// Written only when off: a paragraph saved without it was laid out breaking between Hangul + /// syllables. + #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")] + korean_line_break_anywhere: bool, } impl From<ParaStyle> for ParaStyleFile { @@ -361,6 +377,7 @@ impl From<ParaStyle> for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = p; Self { @@ -386,6 +403,7 @@ impl From<ParaStyle> for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } @@ -415,6 +433,7 @@ impl From<ParaStyleFile> for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = f; Self { @@ -439,6 +458,7 @@ impl From<ParaStyleFile> for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index c4ec92b70b..e213c252bb 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -28,6 +28,15 @@ pub struct TabStop { pub align_on: char, } +impl TabStop { + /// The largest stop position `text.tabs.set` and a paragraph style take, in points. + pub const MAX_POSITION: f64 = 100_000.0; + /// The most leader characters a stop repeats. + pub const MAX_LEADER: usize = 8; + /// The most stops `text.tabs.set` and a paragraph style take in one list. + pub const MAX_STOPS: usize = 256; +} + fn default_align_on() -> char { '.' } @@ -356,6 +365,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index aa403b4218..3b3781f87c 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() { assert_eq!(same, d); } +/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph +/// saved before it existed, or a text object saved without paragraph attributes, reads with it on, +/// and both values read back as saved. +#[test] +fn korean_line_break_anywhere_is_saved_only_when_off() { + let off = ParaStyle::default(); + assert!(!off.korean_line_break_anywhere); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["korean_line_break_anywhere"], false); + assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), off); + let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }; + let saved = serde_json::to_value(&on).unwrap(); + assert!(saved.get("korean_line_break_anywhere").is_none()); + assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), on); + let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap(); + assert!(old.korean_line_break_anywhere); + let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap(); + assert_eq!(saved["para"]["korean_line_break_anywhere"], false); + saved.as_object_mut().unwrap().remove("para"); + let old: TextObject = serde_json::from_value(saved).unwrap(); + assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }); +} + /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5c18f2d5f0..866d9b32f3 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,7 @@ -use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; +use super::{ + CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, + json, trap, +}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] @@ -81,6 +84,7 @@ fn lengths_of(id: &str) -> Lengths { "threeD.revolve" => always(&["offset"]), "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), "threeD.classicExtrude" => always(&["depth", "bevelHeight"]), + "threeD.classicRevolve" => always(&["offset"]), "threeD.inflate" => always(&["depth"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), @@ -207,6 +211,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), ), + g( + CLASSIC_REVOLVE, + "Revolve (Classic)…", + &["Effect", "3D and Materials", "3D (Classic)"], + "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", + json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + ), + g( + CLASSIC_ROTATE, + "Rotate (Classic)…", + &["Effect", "3D and Materials", "3D (Classic)"], + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + ), g( "convertToShape.rectangle", "Rectangle…", diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..92ee086147 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -83,12 +83,14 @@ pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; -pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in}; +pub use revolve::{ + CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, +}; pub use solids::{ - CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, - classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, - three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, - validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, + classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, + validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..c73d76e2c8 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -1,4 +1,5 @@ -//! Revolve as live, shaded vector art, shared by the canvas and every exporter. +//! Revolve and Revolve (Classic) as live, shaded vector art, shared by the canvas and every +//! exporter. use std::sync::Arc; use serde_json::Value; @@ -13,6 +14,14 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; pub const REVOLVE: &str = "threeD.revolve"; +mod classic; +pub use classic::CLASSIC_REVOLVE; + +/// Is `id` Revolve or Revolve (Classic), the effects this module draws? +pub fn is_revolve(id: &str) -> bool { + id == REVOLVE || id == CLASSIC_REVOLVE +} + /// Most bytes the copies of the object's paint in the faces may take together. Each face and the /// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of /// thousands of faces took gigabytes; past this the source path is drawn. @@ -55,7 +64,7 @@ pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) - } fn visible(fx: &[Effect]) -> bool { - fx.iter().any(|e| e.visible && e.id == REVOLVE) + fx.iter().any(|e| e.visible && is_revolve(&e.id)) } pub fn has_revolve(n: &Node) -> bool { @@ -88,6 +97,17 @@ pub fn revolve_options(p: &Value) -> Revolve { } } +/// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with +/// parameters `p` sweeps its surface with. +fn options_of(id: &str, p: &Value) -> Revolve { + if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) } +} + +/// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with. +fn fx_options(fx: &Effect) -> Revolve { + options_of(&fx.id, &merged_params(&fx.id, &fx.params)) +} + /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { @@ -97,11 +117,16 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { /// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group /// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now. pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { - validate_revolve_params(p)?; + validate_in(doc, n, REVOLVE, p) +} + +/// [`validate_revolve_in`] for Revolve or Revolve (Classic), `id`. +pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_params_of(id, p)?; let n = &*resolve_for(doc, n, "Revolve", false)?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. - let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + let before = effects.iter().position(|e| is_revolve(&e.id)).and_then(|i| effects.get(..i)).unwrap_or(effects); if is_container(n) { const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; let mut shapes = members(n, "Revolve")?; @@ -110,7 +135,7 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul } reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or(EMPTY)?; - let options = shared_options(&shapes, revolve_options(p))?; + let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); @@ -123,8 +148,8 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?; - check_shadow(p, [&path], b, revolve_options(p)) + check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + check_shadow(p, [&path], b, options_of(id, p)) } /// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast @@ -138,14 +163,21 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator<Item = &'a PathData>, b: } /// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of -/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve, -/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level -/// Revolve draws in place of those surfaces. +/// appearance item `target` of `n` ([`validate_item_of`]). +#[cfg(test)] pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { + validate_item_of(n, REVOLVE, p, target, index) +} + +/// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index +/// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke +/// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible +/// object-level Revolve draws in place of those surfaces. +pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { - return validate_revolve(n, p); + return validate_in(None, n, id, p); } - validate_revolve_params(p)?; + validate_params_of(id, p)?; const EMPTY: &str = "Revolve requires a shape with at least two anchors"; let (path, _) = object_profile(n, "Revolve")?; if !has_two_anchors(&path) { @@ -156,13 +188,13 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u let mut copies = 0; for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); - let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; - let params = match at.and_then(|at| effects.get(at)) { - _ if i == target => p.clone(), - Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params), + let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) }; + let (kind, params) = match at.and_then(|at| effects.get(at)) { + _ if i == target => (id, p.clone()), + Some(fx) if crate::paints(item) => (fx.id.as_str(), merged_params(&fx.id, &fx.params)), _ => continue, }; - let options = revolve_options(¶ms); + let options = options_of(kind, ¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; @@ -217,6 +249,16 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } +/// [`validate_revolve_params`] for Revolve or Revolve (Classic), `id`; Revolve (Classic) also takes +/// only its own parameters, each in its range. +pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { + validate_revolve_params(p)?; + if id == CLASSIC_REVOLVE { + classic::validate(p)?; + } + Ok(()) +} + fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } @@ -226,7 +268,7 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when /// the surface it is cast from is past the limits. fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result<Option<Node>, String> { - let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) }; + let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) }; let (_, opacity, blend) = paint_of(item); if opacity <= 0.0 { return Ok(None); @@ -242,7 +284,7 @@ fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: /// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the /// limits. fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result<Vec<Arc<Node>>, String> { - let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) }; + let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) }; let mut caster = vectorcraft_three_d::ShadowCaster::new(); let mut cast = vec![]; for s in shapes { @@ -259,7 +301,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R } /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts -/// the bytes of the paint copies of every surface of the object. +/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face +/// with its classic surface, as Extrude & Bevel (Classic) does. #[allow(clippy::too_many_arguments)] fn surface( n: &Node, @@ -275,6 +318,16 @@ fn surface( ) -> Option<(Vec<f64>, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. + if fx.id == CLASSIC_REVOLVE { + let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; + *copies = copies.saturating_add(paint_copies(facets.len(), &paint)); + if *copies > MAX_PAINT_COPIES { + return None; + } + let depths = facets.iter().map(|f| f.depth).collect(); + let p = merged_params(&fx.id, &fx.params); + return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); @@ -371,8 +424,7 @@ fn surface( fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option<Node> { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - let options = revolve_options(&merged_params(REVOLVE, &fx.params)); - surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -429,10 +481,10 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> { let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let (paint, opacity, _) = paint_of(item); - let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, fx_options(fx))?; Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) }; - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let before = n.appearance.effects.get(..index).unwrap_or_default(); let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, before, &ctx); @@ -445,7 +497,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); @@ -461,13 +513,13 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate:: .appearance .effects .iter() - .position(|e| e.visible && e.id == REVOLVE) + .position(|e| e.visible && is_revolve(&e.id)) .ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let mut shapes = members(n, "Revolve")?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let options = shared_options(&shapes, fx_options(fx))?; let mut out = vec![]; for s in &shapes { let Some(item) = surface_item(&s.items) else { continue }; @@ -491,7 +543,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { let mut children = vec![]; let mut copies = 0; let mut remaining = n.appearance.effects.clone(); - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = n.appearance.effects.get(index)?; let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); // An object's surface uses its top visible fill, falling back to its stroke. @@ -500,7 +552,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? { children.push(Arc::new(shadow)); } @@ -511,7 +563,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { .effects .iter() .enumerate() - .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index)) + .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) .map(|(_, e)| e.clone()) .collect(); } else { @@ -520,14 +572,14 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; - art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() - && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? { shadow.appearance.effects = art.appearance.effects.clone(); shadows.push(Arc::new(shadow)); @@ -666,12 +718,12 @@ fn overlaps(a: Rect, b: Rect) -> bool { /// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option<Node> { - let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; + let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?; let fx = n.appearance.effects.get(index)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let b = union_bounds(&shapes)?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + let options = shared_options(&shapes, fx_options(fx)).ok()?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = 0; let mut surfaces = vec![]; @@ -690,6 +742,10 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { return None; } let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + // A classic surface whose gradient has transparent stops carries an opacity mask: its faces go + // in runs, as a transparent member's do, and each run carries the mask. + let masks: Vec<Option<Box<vectorcraft_doc::OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); + let opaque = opaque && masks.iter().all(Option::is_none); let children = if !plain { scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) } else { @@ -731,7 +787,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); continue; }; - if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { + let masked = masks.get(member).is_some_and(Option::is_some); + if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() && !masked { runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); continue; } @@ -767,6 +824,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { let mut group = Node::group(n.id, r.faces); group.opacity = r.opacity; group.blend = r.blend; + if let Some(mask) = r.member.and_then(|m| masks.get(m)).cloned().flatten() { + group.extras_mut().mask = Some(mask); + } Some((r.scopes, Arc::new(group))) }); scoped(n.id, runs.collect(), 0) diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs new file mode 100644 index 0000000000..1881381567 --- /dev/null +++ b/crates/effects/src/revolve/classic.rs @@ -0,0 +1,220 @@ +//! Revolve (Classic): the surface Revolve sweeps from a path, drawn with the classic surfaces of +//! Extrude & Bevel (Classic) (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend +//! steps and shading color. +use serde_json::Value; +use vectorcraft_three_d::Revolve; + +pub const CLASSIC_REVOLVE: &str = "threeD.classicRevolve"; + +/// The numeric parameters: (key, lowest, highest, whole numbers only). +const RANGES: [(&str, f64, f64, bool); 13] = [ + ("angle", 0.0, 360.0, false), + ("offset", 0.0, 1000.0, false), + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("highlightIntensity", 0.0, 100.0, false), + ("highlightSize", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +const FLAGS: [&str; 2] = ["cap", "lightBehind"]; + +/// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge, +/// rotation and perspective, built unshaded, since the classic surfaces light its faces. +pub(crate) fn options(p: &Value) -> Revolve { + Revolve { shade: false, smooth_shading: false, lights: None, light_color: [1.0; 3], ..super::revolve_options(p) } +} + +/// Check Revolve (Classic)'s parameters `p` (its defaults filled in) beyond the checks it shares +/// with Revolve: every key is one of its parameters, numbers are in range, flags are true or false, +/// and the surface and shading color are named. +pub(crate) fn validate(p: &Value) -> Result<(), String> { + crate::solids::classic::validate_classic(CLASSIC_REVOLVE, p, &RANGES, &FLAGS) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::color::{Color, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId}; + use vectorcraft_geom::{BezPath, PathData}; + + use super::*; + + const BLUE: Color = Color::Rgb { r: 0.2, g: 0.4, b: 0.8 }; + + /// Half a vase's profile, open, filled blue. + fn profile() -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)) + } + + fn revolved(params: Value) -> Node { + let mut n = profile(); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + } + + /// The faces of the surface of `art`, back to front. + fn faces(art: &Node) -> Vec<&Node> { + art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect() + } + + fn fill(face: &Node) -> Option<Color> { + face.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color(), + AppearanceItem::Stroke(_) => None, + }) + } + + fn facets(params: &Value) -> usize { + let n = profile(); + let path = n.path_data().unwrap(); + let p = crate::merged_params(CLASSIC_REVOLVE, params); + vectorcraft_three_d::revolve_facets(path, path.bounds().unwrap(), options(&p)).unwrap().len() + } + + #[test] + fn plastic_shades_every_face_of_the_swept_surface_in_the_fill() { + let art = revolved(json!({})); + let drawn = faces(&art); + assert_eq!(drawn.len(), facets(&json!({})), "one face for each facet of the surface"); + let colors: Vec<[f32; 3]> = drawn.iter().map(|f| fill(f).unwrap().to_rgb()).collect(); + // Lit from one side, the faces take more than one tone, and none is another hue: a face is + // the blue darkened toward black, lightened toward white, or both. + let distinct = colors.iter().filter(|c| colors.iter().any(|d| (c[0] - d[0]).abs() > 0.02)).count(); + assert!(distinct > 0, "{colors:?}"); + // Diffuse has no highlight, so every face is the blue darkened toward black. + let art = revolved(json!({"surface": "diffuse"})); + for face in faces(&art) { + let [r, g, b] = fill(face).unwrap().to_rgb(); + assert!(b <= 0.8 + 1e-6 && (g * 2.0 - b).abs() < 1e-4 && (r * 4.0 - b).abs() < 1e-4, "{r} {g} {b}"); + } + } + + #[test] + fn wireframe_strokes_the_edges_of_every_face_and_no_shading_keeps_the_paint() { + let art = revolved(json!({"surface": "wireframe"})); + let faces_drawn = faces(&art); + assert_eq!(faces_drawn.len(), facets(&json!({"surface": "wireframe"}))); + for face in faces_drawn { + match face.appearance.items.as_slice() { + [AppearanceItem::Stroke(s)] => assert_eq!((s.paint.color(), s.width), (Some(BLUE), 0.5)), + items => panic!("a wireframe face is one stroke: {items:?}"), + } + } + for face in faces(&revolved(json!({"surface": "none"}))) { + assert_eq!(fill(face), Some(BLUE)); + } + } + + #[test] + fn angle_offset_edge_and_cap_shape_the_surface() { + let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); + // Seen from the front, the full turn is twice the profile's width across, about its left edge. + assert!((full.width() - 60.0).abs() < 2.0, "{full:?}"); + let ring = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "offset": 20})).visual_bounds().unwrap(); + assert!((ring.width() - 100.0).abs() < 2.0, "{ring:?}"); + // About its right edge the axis is at x 130, so the surface reaches x 160. + let right = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "edge": "right"})).visual_bounds().unwrap(); + assert!((right.x1 - 160.0).abs() < 2.0, "{right:?}"); + assert!(facets(&json!({"angle": 90})) < facets(&json!({}))); + // The profile's ends lie on the axis, so they close nothing until an offset moves them off it. + assert!(facets(&json!({"cap": false, "offset": 10})) < facets(&json!({"offset": 10})), "hollow leaves out the end caps"); + } + + #[test] + fn a_group_revolves_its_members_about_one_shared_axis() { + let mut b = BezPath::new(); + b.move_to((150.0, 60.0)); + b.line_to((170.0, 60.0)); + b.line_to((170.0, 140.0)); + let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(profile()), std::sync::Arc::new(outer)]); + g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).unwrap()); + let art = crate::revolve_art(&g).unwrap(); + // The axis is at the left edge of both members together, x 100, so the outer one reaches 70 + // out on either side of it. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x0 - 30.0).abs() < 2.0 && (bounds.x1 - 170.0).abs() < 2.0, "{bounds:?}"); + let mut colors = vec![]; + art.walk(&mut |n| colors.extend(fill(n).map(|c| c.to_rgb()))); + assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn"); + } + + /// A group member in a gradient with transparent stops keeps the shared back-to-front order of + /// every member's faces: its faces go in runs that carry its opacity mask, and the other + /// member's faces are drawn between them where they are in front of some and behind others. + #[test] + fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let art = |end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))); + let mut b = BezPath::new(); + b.move_to((150.0, 60.0)); + b.line_to((170.0, 60.0)); + b.line_to((170.0, 140.0)); + let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(outer), std::sync::Arc::new(profile())]); + g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({})).unwrap()); + crate::revolve_art(&g).unwrap() + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + assert!(!order(&art(1.0)).contains('M'), "an opaque gradient needs no mask"); + let masked = order(&art(0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "faces between the member's runs: {masked}"); + } + + #[test] + fn bad_parameters_are_refused() { + let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p)); + assert_eq!(check(json!({})), Ok(())); + for (p, error) in [ + (json!({"segments": 32}), "unknown parameter `segments`"), + (json!({"angle": 400}), "`angle` must be between 0 and 360"), + (json!({"offset": 1001}), "`offset` must be between 0 and 1000"), + (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"), + (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"), + (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"surface": "glossy"}), "`surface` must be one of"), + (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), + (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"edge": "middle"}), "Revolve edge must be left or right"), + (json!({"cap": "yes"}), "Revolve cap must be a boolean"), + ] { + let e = check(p.clone()).unwrap_err(); + assert!(e.contains(error), "{p}: {e}"); + } + } +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..8d9ff9eea8 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1,4 +1,4 @@ -//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art, +//! Extrude & Bevel, Inflate, Rotate, Extrude & Bevel (Classic) and Rotate (Classic) as live, shaded vector art, //! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. use std::sync::Arc; @@ -14,8 +14,8 @@ use vectorcraft_doc::color::Color; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry}; -mod classic; -pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; +pub(crate) mod classic; +pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLORS, classic_extrude_options, classic_shading}; mod source; pub(crate) use source::{resolve, resolve_for}; @@ -26,11 +26,22 @@ pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; /// The 3D effects this module draws (Revolve has its own). -pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE]; +pub const SOLIDS: [&str; 5] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE, CLASSIC_ROTATE]; -/// Is `id` one of the 3D and Materials effects (Revolve included)? +/// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)? pub fn is_three_d(id: &str) -> bool { - id == crate::REVOLVE || SOLIDS.contains(&id) + crate::revolve::is_revolve(id) || SOLIDS.contains(&id) +} + +/// Is `id` one of the 3D (Classic) effects: Extrude & Bevel (Classic), Revolve (Classic) or Rotate +/// (Classic)? +pub fn is_classic_three_d(id: &str) -> bool { + id == CLASSIC_EXTRUDE || id == crate::CLASSIC_REVOLVE || id == CLASSIC_ROTATE +} + +/// Does 3D effect `id` turn the art as a flat plane: Rotate or Rotate (Classic)? +fn is_plane(id: &str) -> bool { + id == ROTATE || id == CLASSIC_ROTATE } /// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). @@ -193,6 +204,9 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { if id == CLASSIC_EXTRUDE { classic::validate(p)?; } + if id == CLASSIC_ROTATE { + classic::validate_rotate(p)?; + } crate::lighting::validate(p)?; shadow::validate(p)?; if let Some(v) = p.get("bevel") @@ -225,7 +239,7 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); - let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; + let n = &*source::resolve_for(doc, n, label, is_plane(id))?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; @@ -417,7 +431,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) } + if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) } } /// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of @@ -436,7 +450,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va Some(_) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } - Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)), None => validate_three_d_in(doc, n, id, p), } @@ -446,7 +460,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) } + if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) } } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and @@ -708,12 +722,12 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurface /// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on /// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the /// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow -/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past +/// off, for Extrude & Bevel (Classic) and Rotate (Classic) or with no shapes. A stroke's solid that is not built (past /// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error. /// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to /// the part of the shadow they cast too ([`shadow::scoped_art`]). fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> { - if id == CLASSIC_EXTRUDE { + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { return Ok(None); } let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; @@ -775,12 +789,12 @@ type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); - let classic = fx.id == CLASSIC_EXTRUDE; + let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; let colored = !classic && view_options(&p).has_colored_lights(); let masks = has_masks(shapes); // The paint copies of every solid together: past the allowance the source path is drawn. let mut paint_bytes = 0usize; - if fx.id == ROTATE { + if is_plane(&fx.id) { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. let mut out = vec![]; @@ -792,7 +806,8 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { return None; } - out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored)))); + let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + out.push((s.scopes.as_slice(), Arc::new(art))); } } return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); @@ -1001,7 +1016,7 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) let view = view_options(&p); let mut out = vec![]; let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit); - if fx.id == ROTATE { + if is_plane(&fx.id) { for s in shapes { for item in &s.items { let (paint, opacity, _) = paint_of(item, s.opacity); diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 730fb8e53b..919de831ce 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -1,6 +1,6 @@ //! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic //! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading -//! color. +//! color. Rotate (Classic): the plane Rotate turns, drawn with the No Shading and Diffuse surfaces. use std::sync::Arc; use serde_json::Value; @@ -12,6 +12,10 @@ use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Ext use crate::util::{flag, num, text}; pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude"; +pub const CLASSIC_ROTATE: &str = "threeD.classicRotate"; + +/// The surfaces Rotate (Classic) takes: No Shading and Diffuse. +pub const ROTATE_SURFACES: [&str; 2] = ["none", "diffuse"]; /// The width of a Wireframe edge, in points. const WIRE: f64 = 0.5; @@ -104,8 +108,39 @@ const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces" /// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and /// `shadingCustom` name a surface, a shading color and a #rrggbb color. pub fn validate(p: &Value) -> Result<(), String> { + validate_classic(CLASSIC_EXTRUDE, p, &RANGES, &FLAGS) +} + +/// Rotate (Classic)'s numeric parameters: (key, lowest, highest, whole numbers only). +const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +/// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude & +/// Bevel (Classic)'s, with `surface` No Shading or Diffuse only. +pub fn validate_rotate(p: &Value) -> Result<(), String> { + validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?; + if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) { + return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", "))); + } + Ok(()) +} + +/// Check the parameters `p` of classic 3D effect `id` (its defaults filled in): every key is one of +/// its parameters, the numbers `ranges` lists, as (key, lowest, highest, whole numbers only), are in +/// range, the `flags` are true or false, and `surface`, `shadingColor` and `shadingCustom` name a +/// surface, a shading color and a #rrggbb color. +pub(crate) fn validate_classic(id: &str, p: &Value, ranges: &[(&str, f64, f64, bool)], flags: &[&str]) -> Result<(), String> { let Some(map) = p.as_object() else { return Ok(()) }; - let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null); + let defaults = crate::default_params(id).unwrap_or(Value::Null); let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default(); for key in map.keys() { if !known.contains(&key.as_str()) { @@ -113,7 +148,7 @@ pub fn validate(p: &Value) -> Result<(), String> { return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); } } - for (key, min, max, whole) in RANGES { + for &(key, min, max, whole) in ranges { let Some(v) = map.get(key) else { continue }; let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) }; if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) { @@ -123,7 +158,7 @@ pub fn validate(p: &Value) -> Result<(), String> { return Err(format!("`{key}` must be between {min} and {max} (got {x})")); } } - for key in FLAGS { + for &key in flags { if map.get(key).is_some_and(|v| !v.is_boolean()) { return Err(format!("`{key}` must be true or false")); } @@ -161,7 +196,7 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color { /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by /// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by /// its own transparency, so the shading follows the gradient's coverage. -pub(super) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { +pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { let shading = classic_shading(p); let lights = lights(p); let shade = shading_color(p); diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 66ca2c1d72..585f23bd6b 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -55,7 +55,7 @@ fn needs(n: &Node) -> bool { /// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut /// for the solid effect `n` carries ([`resolve_for`]). pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> { - let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); + let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| super::is_plane(&fx.id)); resolve_for(doc, n, label, every_fill) } diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 248473f428..c960bbe671 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -889,3 +889,80 @@ fn classic_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// The red square, stroked black, with Rotate (Classic) and `params`: its surfaces, the fill's +/// plane first, then the stroke's outline. +fn classic_rotate(params: Value) -> Vec<Node> { + let mut n = square_node(); + n.appearance.effects.push(fx(CLASSIC_ROTATE, params)); + solid_art(&n).unwrap().children().unwrap().iter().map(|s| (**s).clone()).collect() +} + +#[test] +fn classic_rotate_turns_every_fill_and_stroke_as_one_plane() { + let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0}); + let surfaces = classic_rotate(front.clone()); + assert_eq!(surfaces.len(), 2, "the fill's plane and the stroke's outline"); + assert!(surfaces.iter().all(|s| s.children().unwrap().len() == 1), "each is one flat face"); + let fill = surfaces[0].visual_bounds().unwrap(); + assert!((fill.width() - 100.0).abs() < 1.0 && (fill.height() - 100.0).abs() < 1.0, "{fill:?}"); + // Turned 60° about the vertical axis, the square shows half its width and all its height, + // about the center of its bounds. + let turned = classic_rotate(json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}))[0].visual_bounds().unwrap(); + assert!((turned.width() - 50.0).abs() < 1.0 && (turned.height() - 100.0).abs() < 1.0, "{turned:?}"); + assert!((turned.center().x - 150.0).abs() < 1.0, "{turned:?}"); + // Turned past 90°, the back of the plane is drawn. + let back = classic_rotate(json!({"rotationX": 0, "rotationY": 150, "rotationZ": 0})); + assert!(back[0].visual_bounds().is_some_and(|b| (b.width() - 100.0 * 30f64.to_radians().cos()).abs() < 1.0)); +} + +#[test] +fn classic_rotate_shades_with_no_shading_or_diffuse() { + let rgb = |p: Value| fill_rgb(&classic_rotate(p)[0].children().unwrap()[0]); + // No Shading keeps the paint, the color itself kept. + let mut n = square_node(); + n.appearance.effects.push(fx(CLASSIC_ROTATE, json!({"surface": "none"}))); + let art = solid_art(&n).unwrap(); + let AppearanceItem::Fill(f) = &faces(&art)[0].appearance.items[0] else { panic!("a fill first") }; + assert_eq!(f.paint, Paint::solid(RED)); + // Diffuse: straight on with the light in front, the face is fully lit; turned away from the + // light, it is the red darkened toward black. + let lit = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "lightHeight": 90}); + assert!(close3(rgb(lit), RED.to_rgb())); + let [r, g, b] = rgb(json!({"rotationX": 0, "rotationY": 70, "rotationZ": 0, "lightHeight": 90})); + assert!(r < 0.8 - 0.05 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4, "{r} {g} {b}"); + // No light: the ambient share of the red. A custom shading color darkens toward that color; + // None keeps the paint. + let dark = rgb(json!({"lightIntensity": 0, "ambient": 20, "blendSteps": 256})); + assert!((dark[0] - 0.8 * 0.2).abs() < 0.01, "{dark:?}"); + let blue = rgb(json!({"lightIntensity": 0, "ambient": 20, "shadingColor": "custom", "shadingCustom": "#0000ff"})); + assert!(blue[2] > blue[0] / 8.0 + 0.01, "{blue:?}"); + assert!(close3(rgb(json!({"lightIntensity": 0, "shadingColor": "none"})), RED.to_rgb())); +} + +#[test] +fn classic_rotate_parameters_are_checked() { + let n = square_node(); + let check = |p: Value| validate_solid(&n, CLASSIC_ROTATE, &crate::merged_params(CLASSIC_ROTATE, &p)); + assert!(check(json!({})).is_ok()); + assert!(check(json!({"surface": "none", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 1})).is_ok()); + for (p, says) in [ + (json!({"surface": "plastic"}), "`surface` must be one of none, diffuse"), + (json!({"surface": "wireframe"}), "`surface` must be one of none, diffuse"), + (json!({"surface": 2}), "`surface` must be one of"), + (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"), + (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"rotationX": 181}), "`rotationX` must be between -180 and 180 (got 181)"), + (json!({"perspectiveAngle": 161}), "`perspectiveAngle` must be between 0 and 160 (got 161)"), + (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"), + (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), + (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"), + (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), + (json!({"shadow": true}), "unknown parameter `shadow`"), + (json!({"lights": []}), "lights must contain one to ten lights"), + ] { + let err = check(p.clone()).unwrap_err(); + assert!(err.starts_with(says), "{p}: {err}"); + } +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b8c1f82e50..465025302f 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt Some(v) => Ok(Some(vec![checked_id(s, v, command)?])), } } +/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form: +/// for a command that looks its objects up itself, outside the document's layers too. +pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> { + if p.get("ids").is_some_and(|v| !v.is_null()) { + return ids_param(p, "ids", command); + } + match p.get("id").filter(|v| !v.is_null()) { + None => Ok(None), + Some(Value::String(r)) if r.starts_with('$') => { + Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) + } + Some(v) => { + let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?; + Ok(Some(vec![id])) + } + } +} /// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list. pub(crate) fn index(v: u64) -> usize { usize::try_from(v).unwrap_or(usize::MAX) diff --git a/crates/engine/src/cmd/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/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index bc1d4e4f25..537e6bfe75 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -21,7 +21,7 @@ use super::super::menucmds::whole_or; use super::super::object::choice; use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; use super::super::typecmd::refresh_bounds; -use super::super::typemenu::text_ids; +use super::super::typemenu::{bake_dynamic_text, text_ids}; use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets}; use crate::{EngineError, Result, Session}; @@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> { /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the -/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for -/// other type, or when it can't be placed. +/// object's own text space, so its transform stays). Type on a Path Options are dropped, and +/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it +/// can't be placed. fn released_text(t: &TextObject) -> Option<TextObject> { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); @@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option<TextObject> { (p.x.is_finite() && p.y.is_finite()).then_some(p) })?; let mut out = t.clone(); + // Point type has no brackets to fit: it keeps the sizes the path drew it at. + bake_dynamic_text(&mut out); out.kind = TextKind::Point; out.path_effect = Default::default(); out.path_align = Default::default(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 1f64e582ad..fb86fa7852 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -32,8 +32,8 @@ use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ - CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, - ok, paint_targets, str_param, switch_or, targets, + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, + object_param, ok, paint_targets, str_param, switch_or, targets, }; use crate::EngineError; @@ -726,8 +726,8 @@ fn clear(n: &mut Node) { fn transfer(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "appearance.transfer"; - let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?; + let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?; if source == target { return Err(bad(C, "`source` and `target` are the same object")); } @@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> { /// Sample enabled attributes with transform-aware character dimensions and atomic validation. fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "appearance.copyFrom"; - let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; + let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); let defaults_only = switch_or(p, "defaultsOnly", false, C)?; if defaults_only && (reverse || append || p.get("ids").is_some()) { @@ -1512,6 +1512,9 @@ impl Picked { stroke_width: st.stroke_width, overprint_fill: st.overprint_fill, overprint_stroke: st.overprint_stroke, + // The space before a line's first character belongs to where the character is, + // not to how it looks: a sample from one doesn't move the target's (#5111). + kerning_before: st.kerning_before, ..cs.clone() }; } diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..981f85e6f5 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -11,7 +11,9 @@ use std::collections::HashSet; use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; -use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_brush::{ + self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation, +}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes}; @@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary { pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, + BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, @@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary { built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes) } +/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity +/// and stiffness (%). +fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush { + Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) } +} + +/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for +/// blending and texture. +fn bristle_brushes() -> BrushLibrary { + use BristleShape::*; + let brushes = vec![ + bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]), + bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]), + bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]), + bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]), + bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]), + bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]), + bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]), + bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]), + bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]), + bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]), + ]; + built("Bristle Brushes", Document::new(100.0, 100.0), brushes) +} + fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush { let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() }); Brush { name: name.into(), kind } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index b4b231a22b..fb8af2b11e 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -33,10 +33,10 @@ mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; use brushes::{ - brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, - brush_selection_art, brush_set_current, brush_unused, expand_brush, + brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art, + brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -pub(crate) use dynamic::{edit_part, part_only, selected_part}; +pub(crate) use dynamic::{edit_part, fit_parts, part_only, selected_part}; use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ @@ -67,6 +67,24 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, brush_selection_art ), + cmd!( + "brush.placeArt", + "Place Brush Art", + [], + None, + "{name, x?, y?} place a copy of the art a scatter, art or pattern brush is made of, centered on (x, y) (default: the center of the first artboard), and select it, as dragging the brush from the Brushes panel onto an empty part of the canvas does; a pattern brush places its tiles left to right (side, outer corner, inner corner, start, end; the ones it has art for) 10 pt apart, each in a group named Side Tile, Outer Corner Tile, Inner Corner Tile, Start Tile or End Tile, with an unpainted rectangle at the back of a tile that has a repeat frame marking that frame; a calligraphic or bristle brush is refused → {id}", + has_doc, + brush_place_art + ), + cmd!( + "brush.replaceArt", + "Replace Brush Art", + [], + None, + "{name, ids?|id?, applyToStrokes?: true} make the selected art (several objects become one group, as in brush.new) the art of a scatter, art or pattern brush, keeping its other options, as Alt-dragging (Option-dragging on macOS) art onto the brush in the Brushes panel does; a pattern brush takes a group named for one of its tiles, or a group of such groups (as brush.placeArt places them), tile by tile (of two named for one tile, the frontmost), each with the repeat frame an unpainted rectangle at its back marks (other tiles stay as they are), and other art as its side tile; when strokes use the brush and applyToStrokes is false, the brush and its strokes stay as they were and a copy of the brush with the new art, named `<name> copy`, goes after it and becomes the current brush; otherwise the strokes redraw with the new art; one undo step; refused for a calligraphic or bristle brush, with nothing selected, and for art (or a tile) with no width → {name: the brush that has the new art}", + has_doc, + brush_replace_art + ), cmd!( "brush.apply", "Apply Brush", diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index cf5dedb6bd..1f63076cfd 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -239,7 +239,7 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> { && v.get(k).is_none() { let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; - if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + if !has_width(&art, &s.doc()?.doc.symbol_boxes()) { return Err(bad(C, "the selected art has no width")); } v[k] = serde_json::to_value(&art).unwrap_or_default(); @@ -249,6 +249,12 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "name": name })) } +/// Whether art `n` has width for a brush to be made of, with symbol instances measured by their +/// symbols' art in `symbols`. +fn has_width(n: &Node, symbols: &vectorcraft_doc::SymbolBoxes) -> bool { + n.geometric_bounds_with(symbols).is_some_and(|b| b.width() > 1e-6) +} + /// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); @@ -367,6 +373,12 @@ fn art_brushes(n: &Node, out: &mut Vec<String>) { pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> { let d = &s.doc()?.doc; let lib = brush::library(d); + let names = brush_unused_in(d, &lib); + Ok(json!({ "names": names })) +} + +/// The brushes of `lib` no stroke in `d` paints with, in library order ([`brush_unused`]). +fn brush_unused_in(d: &Document, lib: &[Brush]) -> Vec<String> { let mut todo = vec![]; let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); @@ -379,8 +391,7 @@ pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> { used.insert(name); } } - let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); - Ok(json!({ "names": names })) + lib.iter().map(|b| b.name.clone()).filter(|n| !used.contains(n)).collect() } pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { @@ -483,6 +494,217 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "name": out })) } +/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it +/// places them; `brush.replaceArt` reads a group of that name as that tile. +const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"]; + +/// The space between the pattern brush tiles `brush.placeArt` places, in points. +const TILE_GAP: f64 = 10.0; + +/// The message for a brush that is not made of art. +fn no_art(name: &str) -> String { + format!("`{name}` has no art (only scatter, art and pattern brushes do)") +} + +/// Tile `i` of `p` ([`TILE_NAMES`] order): its art and its repeat frame, when it has art. +fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_geom::Rect>)> { + match i { + 0 => Some((&p.side, p.side_frame)), + 1 => p.outer_corner.as_ref().map(|n| (n, p.outer_corner_frame)), + 2 => p.inner_corner.as_ref().map(|n| (n, p.inner_corner_frame)), + 3 => p.start.as_ref().map(|n| (n, p.start_frame)), + _ => p.end.as_ref().map(|n| (n, p.end_frame)), + } +} + +/// Gives tile `i` of `p` ([`TILE_NAMES`] order) the art `art` and the repeat frame `frame`. The tile +/// no longer comes from a pattern swatch, and a corner draws its own art. +fn set_tile(p: &mut brush::PatternBrush, i: usize, art: Node, frame: Option<vectorcraft_geom::Rect>) { + match i { + 0 => (p.side, p.side_frame, p.side_swatch) = (art, frame, None), + 1 => (p.outer_corner, p.outer_corner_frame, p.outer_corner_swatch, p.outer_source) = (Some(art), frame, None, None), + 2 => (p.inner_corner, p.inner_corner_frame, p.inner_corner_swatch, p.inner_source) = (Some(art), frame, None, None), + 3 => (p.start, p.start_frame, p.start_swatch) = (Some(art), frame, None), + _ => (p.end, p.end_frame, p.end_swatch) = (Some(art), frame, None), + } +} + +/// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art +/// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile +/// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame +/// marking that frame. `None` for a brush that is not made of art. Symbol instances in the art are +/// measured by their symbols' art in `symbols`. +fn placed_art(b: &Brush, symbols: &vectorcraft_doc::SymbolBoxes) -> Option<Node> { + match &b.kind { + BrushKind::Scatter(sc) => Some(sc.art.clone()), + BrushKind::Art(a) => Some(a.art.clone()), + BrushKind::Pattern(pb) => { + let mut tiles = vec![]; + let mut x = 0.0; + for (i, tile_name) in TILE_NAMES.iter().enumerate() { + let Some((art, frame)) = tile(pb, i) else { continue }; + let mut children = vec![]; + if let Some(f) = frame { + let marker = Node::path( + NodeId(0), + vectorcraft_geom::shapes::rectangle(f), + vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0), + ); + children.push(Arc::new(marker)); + } + children.push(Arc::new(art.clone())); + let mut g = Node::group(NodeId(0), children); + g.name = Some((*tile_name).to_string()); + let Some(r) = g.geometric_bounds_with(symbols) else { continue }; + // Each tile sits right of the one before it, centered on the line y = 0. + g.transform(vectorcraft_geom::Affine::translate((x - r.x0, -r.center().y)), false); + x += r.width() + TILE_GAP; + tiles.push(Arc::new(g)); + } + Some(Node::group(NodeId(0), tiles)) + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => None, + } +} + +/// `brush.placeArt`: a copy of the art brush `name` is made of, centered on (`x`, `y`) (default: the +/// center of the first artboard) in the layer new art goes into, and selected, as one undo step. +/// Scatter and art brushes place their art; pattern brushes place their tiles ([`placed_art`]). +pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "brush.placeArt"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let center = d.artboards.first().map(|a| a.rect.center()).unwrap_or(super::Point::ZERO); + let at = super::Point::new(super::number_or(p, "x", center.x, C)?, super::number_or(p, "y", center.y, C)?); + let b = brush::find(d, &name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let symbols = d.symbol_boxes(); + let mut art = placed_art(&b, &symbols).ok_or_else(|| bad(C, no_art(&name)))?; + if let Some(r) = art.geometric_bounds_with(&symbols) { + art.transform(vectorcraft_geom::Affine::translate(at - r.center()), false); + } + let parent = s.doc()?.target_parent()?; + let id = s.edit("Place Brush Art", |d, sel| { + let art = d.reid(&art); + let id = art.id; + d.insert(parent, usize::MAX, art)?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The repeat frame an unpainted rectangle at the back of a tile group marks ([`placed_art`]): the +/// rectangle's bounds, when `n` is a path of one closed subpath through the four corners of its +/// bounds, without curves, whose fills and strokes paint nothing and that has no other appearance. +fn frame_marker(n: &Node) -> Option<vectorcraft_geom::Rect> { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return None }; + let unpainted = n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none(), + AppearanceItem::Stroke(st) => st.paint.is_none(), + }); + let [sp] = path.subpaths.as_slice() else { return None }; + let r = n.geometric_bounds()?; + let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)]; + let at_corners = sp.anchors.len() == 4 + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p) + && corners.iter().all(|c| sp.anchors.iter().any(|a| (a.p.x, a.p.y) == *c)); + (unpainted && sp.closed && at_corners && r.width() > 1e-9 && r.height() > 1e-9).then_some(r) +} + +/// The tiles in `art` for a pattern brush, by tile ([`TILE_NAMES`] order), with their repeat frames: +/// a group named for a tile, as `brush.placeArt` places them, is the art of its tile (an unpainted +/// rectangle at its back marks the tile's frame, [`frame_marker`], and is left out of the art; a +/// group left with one member gives that member), and so is each member of a group whose members +/// are all such groups; any other art is the side tile. +fn tiles_of(art: Node) -> Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> { + let named = |c: &Node| matches!(c.kind, NodeKind::Group { clip: false, .. }) && c.name.as_deref().is_some_and(|n| TILE_NAMES.contains(&n)); + let tiles = match &art.kind { + _ if named(&art) => vec![Arc::new(art)], + NodeKind::Group { children, clip: false } if !children.is_empty() && children.iter().all(|c| named(c)) => children.clone(), + _ => return vec![(0, art, None)], + }; + let mut out: Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> = vec![]; + for t in tiles { + let Some(i) = t.name.as_deref().and_then(|n| TILE_NAMES.iter().position(|x| *x == n)) else { continue }; + let mut g = (*t).clone(); + let mut frame = None; + if let NodeKind::Group { children, .. } = &mut g.kind + && let Some(f) = children.first().and_then(|c| frame_marker(c)) + { + frame = Some(f); + children.remove(0); + } + g.name = None; + // The group `brush.placeArt` put around the tile's art: the art itself goes back, so a + // tile does not gain a group each time it goes out and back. + if let NodeKind::Group { children, clip: false } = &g.kind + && let [only] = children.as_slice() + && g == Node::group(g.id, children.clone()) + { + g = (**only).clone(); + } + // A tile named twice: the frontmost one counts. + out.retain(|(j, ..)| *j != i); + out.push((i, g, frame)); + } + out +} + +/// `brush.replaceArt`: the selected art (or `ids`/`id`; several objects become one group, as in +/// `brush.new`) becomes the art of scatter, art or pattern brush `name`, keeping its other options, +/// as one undo step. A pattern brush takes the tiles `brush.placeArt` places by their names +/// ([`tiles_of`]) and other art as its side tile. When strokes use the brush and `applyToStrokes` is +/// false, the brush and its strokes stay as they were and a copy of the brush with the new art is +/// added after it and becomes the current brush; otherwise the strokes redraw with the new art. +pub(super) fn brush_replace_art(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "brush.replaceArt"; + let name = name_param(p, C)?.to_string(); + let apply = super::switch_or(p, "applyToStrokes", true, C)?; + let mut lib = brush::library(&s.doc()?.doc); + let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if lib[i].art().is_empty() { + return Err(bad(C, no_art(&name))); + } + let art = selection_art(s, p)?.ok_or_else(|| bad(C, "nothing selected"))?; + let d = &s.doc()?.doc; + let symbols = d.symbol_boxes(); + let check = |n: &Node| if has_width(n, &symbols) { Ok(()) } else { Err(bad(C, "the selected art has no width")) }; + let mut b = lib[i].clone(); + match &mut b.kind { + BrushKind::Scatter(sc) => { + check(&art)?; + sc.art = art; + } + BrushKind::Art(a) => { + check(&art)?; + a.art = art; + } + BrushKind::Pattern(pb) => { + for (t, art, frame) in tiles_of(art) { + check(&art)?; + set_tile(pb, t, art, frame); + } + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return Err(bad(C, no_art(&name))), + } + let used = !brush_unused_in(d, &lib).contains(&name); + if apply || !used { + lib[i] = b; + save_library(s, "Replace Brush Art", lib, &[])?; + return Ok(json!({ "name": name })); + } + b.name = brush::unique_name(&lib, &format!("{} copy", name.trim())); + let copy = b.name.clone(); + lib.insert(i + 1, b); + s.edit("Replace Brush Art", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(copy)); + brush::store(d, &lib); + Ok(()) + })?; + Ok(json!({ "name": copy })) +} + pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> { let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 888e450085..f5cef8351e 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -2,7 +2,7 @@ //! appearance of objects in the symbol's art and stays linked to the symbol //! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance //! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still -//! there ([`fit_overrides`]). +//! there and draws them ([`fit_overrides`]). use std::sync::Arc; @@ -27,7 +27,7 @@ pub(super) fn type_id(dynamic: bool) -> &'static str { } /// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or -/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art. +/// missing) symbol keeps none, an instance of a dynamic one those its art still draws ([`fit_parts`]). pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { for id in ids { let art = match d.node(*id) { @@ -40,7 +40,7 @@ pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { let Some(n) = d.node_mut(*id) else { continue }; let mut e = n.extras_mut(); match art { - Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art), + Some(art) => fit_parts(&mut e.symbol_overrides, &art), None => e.symbol_overrides.clear(), } } @@ -177,17 +177,42 @@ fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<( fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { let asked = p.get("fill").is_some_and(|v| !v.is_null()); let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); - let draws_nothing = |f: &vectorcraft_color::Paint| match f { - vectorcraft_color::Paint::Gradient(_) => true, - vectorcraft_color::Paint::Pattern { .. } => true, - vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, - }; - if asked && is_instance && fill.is_some_and(draws_nothing) { + if asked && is_instance && fill.is_some_and(|f| !stains(f)) { return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); } Ok(()) } +/// Can a symbol instance's fill be `f`? An instance stains its art with a solid fill only. +fn stains(f: &vectorcraft_color::Paint) -> bool { + match f { + vectorcraft_color::Paint::Gradient(_) => false, + vectorcraft_color::Paint::Pattern { .. } => false, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => true, + } +} + +/// Keep the appearance changes `overrides` that name an object of `art` (a symbol's new art), each +/// without a fill or stroke that object does not draw ([`paint_refusal`], [`stains`]); a change +/// left with nothing in it is dropped. +pub(crate) fn fit_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) { + symbol_overrides::retain_parts(overrides, art); + for o in overrides.iter_mut() { + let Some(n) = symbol_overrides::part(art, &o.path) else { continue }; + let instance = matches!(n.kind, NodeKind::SymbolInstance { .. }); + if paint_refusal(n, "fill", FIT).is_some() || (instance && o.fill.as_ref().is_some_and(|f| !stains(f))) { + o.fill = None; + } + if paint_refusal(n, "stroke", FIT).is_some() { + o.stroke = None; + } + } + overrides.retain(|o| !o.is_empty()); +} + +/// The command name [`fit_parts`] gives [`paint_refusal`], whose refusal it does not show. +const FIT: &str = "symbol.update"; + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d392449247..53e1b655a1 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids, - number_or, point_param, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or, + object_param, point_param, str_param, switch_or, }; mod live_paint; @@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), 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..e5e7757486 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, - is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, + json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -272,9 +272,38 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let parse_chunk = |input: &Value, start: Point| -> Result<Vec<Point>> { + let values = input + .as_array() + .filter(|points| !points.is_empty() && points.len() <= b::LP_FILL_CHUNK_POINTS) + .ok_or_else(|| bad(C, "each path chunk must contain 1 to 4096 points"))?; + let points: Vec<_> = values + .iter() + .map(|point| { + let pair = point.as_array().filter(|pair| pair.len() == 2).ok_or_else(|| bad(C, "points must be [x,y] pairs"))?; + let x = pair.first().and_then(Value::as_f64).ok_or_else(|| bad(C, "point x must be a number"))?; + let y = pair.get(1).and_then(Value::as_f64).ok_or_else(|| bad(C, "point y must be a number"))?; + let point = Point::new(x, y); + if !finite(point) { + return Err(bad(C, "points must be finite canvas points")); + } + Ok(point) + }) + .collect::<Result<_>>()?; + if points.first() != Some(&start) { + return Err(bad(C, "path chunks must share their boundary point")); + } + 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"); + let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; let (g, face) = s.edit("Live Paint Bucket", |d, sel| { let gid = match group { @@ -302,7 +331,33 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { .and_then(|n| b::visible_face_at(n, pt)) .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); + let mut targets = 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); @@ -312,7 +367,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "livePaint.strokeEdge"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; @@ -335,7 +390,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { } pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let g = match object_param(p, "group", "livePaint.info")? { + Some(g) => g, + None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?, + }; let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; let faces: Vec<Value> = b::faces(n) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3c829e8a3e..37473cc59f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> { /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of /// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed -/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own. +/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole +/// (Korean Line Break Anywhere off). Imported text keeps its own. pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle { vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, burasagari: vectorcraft_doc::Burasagari::Standard, + korean_line_break_anywhere: false, ..Default::default() } } diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 17638477a9..2dbb7a9a18 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, - matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param, + named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets, }; use crate::EngineError; @@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool { fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.set"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?); if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { @@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> { fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.copy"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); // When the profile comes out as it was (the copy replaces a side of a discontinuous point that has // its widths already), the document and its undo history stay as they are. @@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> { fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.remove"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let indices = match (p.get("indices"), p.get("index")) { (Some(a), _) => point_indices(a), (None, Some(i)) => point_indices(&json!([i])), diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 7499e14943..ff1fbe3591 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -1,7 +1,7 @@ //! Document Info panel data, its categories and the text report (Document Info › Save…, File → //! Package), and Object → Make Pixel Perfect. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { let mut fonts = BTreeSet::new(); let mut images: BTreeMap<String, Value> = BTreeMap::new(); let mut image_rows: Vec<ImageRow> = vec![]; + // Whether the request being served may read each linked file (a relative path read from the + // document's folder), checked once per file. + let mut readable: HashMap<String, bool> = HashMap::new(); + let link_doc = super::links::LinkBase::of(st).doc; + let mut may_read = |stored: &str| { + *readable.entry(stored.to_string()).or_insert_with(|| { + let resolved = super::fileio::document_path(stored, link_doc).ok(); + crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok() + }) + }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); @@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { Some("textObjects") } NodeKind::Image(im) => { + // A linked file outside a confined request's read roots: `link` is null, and the row + // names the file alone. + let link = im.link.as_ref().map(|l| (l, may_read(&l.path))); images.insert( im.key.clone(), - json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }), + json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }), ); let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string()); - image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone()))); + image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() }))); Some("images") } NodeKind::SymbolInstance { symbol, .. } => { diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 7d1c1208d8..d469c7a613 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -18,8 +18,8 @@ use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, - switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok, + str_param, switch_or, }; use crate::EngineError; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index dbca59b00d..10254f3cd3 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,7 +1,7 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; use super::{ Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, - add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, + add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or, }; // ---------- Curvature ---------- @@ -41,7 +41,7 @@ enum CurvatureOp { pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> { use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); check_numbers(p, &["x", "y", "t"], C)?; let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { @@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let curvature = switch_or(p, "curvature", false, C)?; @@ -220,7 +220,7 @@ fn remove_anchors_of( pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let smooth = str_param(p, "to") == Some("smooth"); @@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let seg = usize_req(p, "segment", C)?; let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 126fa57a1b..645c184b14 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -4,7 +4,7 @@ use super::common::{ }; use super::{ Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; /// Split a filled path into connected pieces (each outer contour with its holes). @@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn( pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let anchor = usize_opt(p, "anchor", C)?; let seg = usize_opt(p, "segment", C)?; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 660f52121e..f13b0a0ba4 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -4,7 +4,8 @@ use super::common::{ }; use super::{ Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, - Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, + Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param, + switch_or, }; // ---------- Pencil / Paintbrush ---------- @@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?; let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; @@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). let connect = match p.get("connect").filter(|v| !v.is_null()) { Some(cn) => { - let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?; if other == id { return Err(bad(C, "connect needs a path other than the one extended")); } @@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({ "id": id.0 })); } if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?; let within = number_or(rd, "within", 12.0, C)?; if !within.is_finite() || within < 0.0 { return Err(bad(C, "redraw within must be a finite distance of 0 or more")); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..4f3dc3403a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, - f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, - number_or, ok, point_param, str_param, switch_or, + f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, + object_param, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> { } fn doc_node(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; + let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?; let mut opts = slice_opts(p, "document.node")?; let summary = switch_or(p, "summary", false, "document.node")?; if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 30d927a966..b570ce332a 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> { "Refresh Deferred Effects", ["Effect"], None, - "{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", + "{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", has_deferred, refresh_deferred ), @@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> None => rest.push(object), } } - if rest.is_empty() { + for (id, n) in definition_objects(d, &rest) { + out.entry(id).or_insert((n, false)); + } + out +} + +/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its +/// symbols, patterns and brushes, and opacity masks (in that art or in the layers). +fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap<u64, Arc<Node>> { + let mut out = std::collections::HashMap::new(); + if ids.is_empty() { return out; } + let ids: std::collections::HashSet<u64> = ids.iter().copied().collect(); let brushes = vectorcraft_brush::library(d); let symbols = d.symbols.iter().map(|s| &*s.art); let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)); let layers = d.layers.iter().map(|a| &**a); let mut look = |n: &Node| { - if rest.contains(&n.id.0) { - out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false)); + if ids.contains(&n.id.0) { + out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone())); } }; for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) { @@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> { let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone()); let ids = super::named_ids(p)?; let objects = prune(&doc, &fx); - if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) { - return Err(EngineError::NoNode(*id)); + // An object of the art drawn outside the layers is valid whether or not it has a stage. + let elsewhere: Vec<u64> = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect(); + let found = definition_objects(&doc, &elsewhere); + if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) { + return Err(EngineError::NoNode(NodeId(*id))); } - let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect()); + // Only the objects with a stage have anything to refresh. + let ids: Option<Vec<u64>> = ids.map(|ids| { + let mut ids: Vec<u64> = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }); let counts = fx.refresh(ids.as_deref(), |targets| { let mut renderer = vectorcraft_render::Renderer::new(); let mut whole = Vec::new(); @@ -656,12 +676,10 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> { if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); } - // Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options. - if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) { - return Err(bad( - C, - format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0), - )); + // The classic 3D effects have no lighting presets; their lights are set in their own options. + if let Some(e) = effect.filter(|e| effects::is_classic_three_d(&e.id)) { + let name = effects::effect_info(&e.id).map_or("This effect", |info| info.label.trim_end_matches('…')); + return Err(bad(C, format!("object {}: {name} at index {index} has no lighting presets; set its lights with effect.setParams", id.0))); } } let mut options = p.clone(); diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs index 812ce09664..2dff0d23f1 100644 --- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs +++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs @@ -115,3 +115,32 @@ fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params; assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0))); } + +#[test] +fn lighting_presets_refuse_revolve_classic() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("path.create", &json!({"d":"M100 40 L130 60 L130 140 L100 160"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color":"#3366cc"})).unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicRevolve","item":null,"params":{"surface":"diffuse"}})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"topLeft"})).unwrap_err().to_string(); + assert!(err.contains(&format!("object {id}: Revolve (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); +} + +#[test] +fn lighting_presets_refuse_rotate_classic() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x":100,"y":100,"width":80,"height":60})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicRotate","item":null,"params":{"lightRotation":40}})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"diffuse"})).unwrap_err().to_string(); + assert!(err.contains(&format!("object {id}: Rotate (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); +} diff --git a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs index 122d937b98..104363a142 100644 --- a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs +++ b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs @@ -4,7 +4,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use crate::Session; -const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"]; +const TYPES: [&str; 6] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.classicRotate", "threeD.revolve"]; fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 36ccd7cdb0..9a59d0f2f8 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -1,7 +1,8 @@ //! File → File Info (`file.info`): the document title and its descriptive metadata (author, //! description, keywords, rating, copyright), with the read-only created and modified dates. The -//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info -//! it gives ([`read_pdf`]). +//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]), +//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or +//! `.ai` opens with the File Info it gives ([`read_pdf`]). use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; @@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb pub(crate) fn info_json(d: &Document) -> Value { let m = &d.metadata; json!({ - "title": d.title, + "title": d.info_title(), "author": m.author, "authorTitle": m.author_title, "description": m.description, @@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { _ => return Err(bad(C, "params must be an object")), }; let d = &s.doc()?.doc; - let (mut title, mut m) = (d.title.clone(), d.metadata.clone()); + let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone()); for (k, v) in o { apply(&mut title, &mut m, k, v)?; } let d = &s.doc()?.doc; - if title != d.title || m != d.metadata { + // A title File Info changes becomes the document's own, as it always has (the tab and Save As + // offer it where the document has no file), and replaces the one its file gave. + let retitled = title != d.info_title(); + if retitled { + m.title = None; + } + if retitled || m != d.metadata { s.edit("File Info", |d, _| { - d.title = title; + if retitled { + d.title = title; + } d.metadata = m; Ok(()) })?; @@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { } /// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each -/// field that `file.info` would take (a value it refuses is left out), and the created and -/// modified dates. +/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`]) +/// as the File Info title, and the created and modified dates. pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain }); let fields = [ @@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let _ = apply(&mut title, m, k, &v); } } + m.title = f.title.as_deref().and_then(imported_title); m.created = f.created.or(m.created); m.modified = f.modified.or(m.modified); } @@ -141,6 +151,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { Ok(s.to_string()) } +/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too) +/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters +/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the +/// Untitled-N name New gives (what a document that never had a title writes to its files). +pub(crate) fn imported_title(raw: &str) -> Option<String> { + let words: Vec<String> = + raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); + let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); + let title = title.trim_end(); + super::fileio::is_own_title(title).then(|| title.to_string()) +} + /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring /// case), empty ones dropped. fn keywords(v: &Value) -> Result<Vec<String>> { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6478cc81b8..329988cb34 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `hpgl`: HP-GL export options and plots. //! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no //! built-in format reads). //! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). @@ -40,6 +41,7 @@ mod load; mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod heifread; +mod hpgl; mod jp2read; mod metafile; mod naming; @@ -90,9 +92,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -124,7 +126,9 @@ pub(crate) use access::list_dir; pub use access::write_file_with; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; -pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; +pub(crate) use access::{ + absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, +}; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; #[cfg(test)] @@ -139,6 +143,7 @@ pub(crate) use export_names::{ artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, }; use export_names::{distinct_names_by, write_encoded}; +pub(crate) use formats::is_own_title; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..7b20326106 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) { (out, seen) } +/// May a reply to the request being served name `path`, a file or folder the client did not give? +/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]). +pub(crate) fn shown(path: &str) -> bool { + crate::file_access::check_read(path).is_ok() +} + +/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name, +/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such +/// as `..` or a folder ending in a separator). +pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> { + shown_link(path, None) +} + +/// A link's `stored` path as a reply to the request being served names it: whole when the file it +/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it. +pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> { + if shown(resolved.unwrap_or(stored)) { + return std::borrow::Cow::Borrowed(stored); + } + let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default(); + std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 5e920e1b3c..4953785189 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -536,6 +536,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def | "dxf" | "emf" | "wmf" + | "hpgl" | "ai" ) }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index dd2b257f2a..7f95b8e203 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused", + "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused", has_doc, screens::export_for_screens ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Presets", [], None, - "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset", + "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset", always, screens::presets_list ), @@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Export Presets", [], None, - "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}", + "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}", always, screens::presets_export ), @@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export for Screens Settings", [], None, - "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)", + "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)", has_doc, screens::export_settings ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index b63447e228..16cf3b2631 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export DXF", [], None, - "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] @@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "dxfPreset", ty: "string", default: "\"custom\"", - description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it", }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { @@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "\"png\"", description: "png or jpeg: the files placed images are written to, next to the drawing", }, + FormatOption { + name: "includeImages", + ty: "boolean", + default: "true", + description: "placed images are written, linked to their files; false leaves them and their files out", + }, + FormatOption { + name: "includeHatches", + ty: "boolean", + default: "true", + description: "filled areas get a solid hatch after their outlines; false writes the outlines alone", + }, FormatOption { name: "preserve", ty: "string", @@ -133,6 +145,8 @@ struct Params { scale_lineweights: Option<bool>, colors: Option<Value>, raster_format: Option<String>, + include_images: Option<bool>, + include_hatches: Option<bool>, preserve: Option<String>, alter_paths: Option<bool>, outline_text: Option<bool>, @@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?, raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?, + include_images: q.include_images.unwrap_or(d.include_images), + include_hatches: q.include_hatches.unwrap_or(d.include_hatches), preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?, alter_paths: q.alter_paths.unwrap_or(d.alter_paths), outline_text: q.outline_text.unwrap_or(d.outline_text), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 84188c313a..0f91007515 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> { } // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so // themselves: SVG only when it outlines or embeds fonts). - if (f.raster || matches!(f.id, "eps" | "emf" | "wmf")) + if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl")) && let Some(w) = crate::cmd::fonts::substitution_warning(doc) { enc.warnings.push(w); @@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None }; // `rect`: that area of the page, as if it were the only artboard (the document is not changed). if let Some(rect) = page_rect(f, p)? { let mut q = super::export::without_artboards(p); @@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { "eps" => return super::eps::encode(doc, p), "emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf), "wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf), + "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; @@ -591,7 +592,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) { - vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title)) + vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title())) } else { bytes }; diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a64c74e035..fe962a36ad 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), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document (none when no art shows); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named", + description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document (when no art shows, Export writes none and Save writes it on the first artboard); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named (Save: the first artboard when no art shows)", }, FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, FormatOption { @@ -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<usize>, Rect, Point); + /// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner /// as the origin, or the visible art's bounds with the first artboard's corner. With two or more /// chosen artboards, a central file of the visible art comes first (none when no art shows). -fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Point)>> { +/// `saving`: Save always writes the file it names, so with no visible art that file's region is +/// the first artboard, and the second value is true. +fn pages(doc: &Document, set: &Settings, saving: bool) -> Result<(Vec<Page>, bool)> { let n = doc.artboards.len(); let named = set.boards.resolve(n).map_err(|e| bad(C, e))?; let boards = match (set.use_artboards, named) { @@ -217,17 +222,25 @@ fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Poi }; let corner = |r: Rect| Point::new(r.x0, r.y1); let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect))); + // The visible art's bounds; for Save with no visible art, the first artboard (true). + let visible = || match vectorcraft_render::encode::art_bounds(doc) { + Some(bounds) => Some((bounds, false)), + None if saving => doc.artboards.first().map(|a| (a.rect, true)), + None => None, + }; match boards { Some(boards) => { - let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten(); + let central = (boards.len() > 1).then(visible).flatten(); let each = boards .into_iter() .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))); - central.map(Ok).into_iter().chain(each).collect() + let pages = central.map(|(bounds, _)| Ok(art(bounds))).into_iter().chain(each).collect::<Result<_>>()?; + Ok((pages, central.is_some_and(|(_, blank)| blank))) } None => { - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - Ok(vec![art(bounds)]) + let missing = if saving { "the document has no artboard" } else { "nothing to export: the document has no visible art" }; + let (bounds, blank) = visible().ok_or_else(|| bad(C, missing))?; + Ok((vec![art(bounds)], blank)) } } } @@ -293,16 +306,17 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> { encode_document(doc, p, false) } -/// Save keeps a single page's original frame and all native editing data, unlike Export's -/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply. +/// Save writes the pages Export does (the visible art's bounds, or with Use Artboards the chosen +/// artboards), with a smaller preview. A single file, the one the document becomes, carries all +/// the native editing data; with no visible art its page is the first artboard. pub(super) fn encode_save(doc: &Document, p: &Value) -> Result<Encoded> { encode_document(doc, p, true) } fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let set = parse(p)?; - if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) { - return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS")); + if saving && bool_or(p, "selectedOnly", false) { + return Err(bad(C, "EPS Save writes all artwork: use document.export for selected-only EPS")); } let original = doc; let (full, warnings) = crate::cmd::place::document::full_documents(doc); @@ -315,18 +329,16 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { } else { Cow::Borrowed(original) }; - let pages = if saving { - let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?; - let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect; - vec![(Some(board), rect, Point::new(rect.x0, rect.y1))] - } else { - pages(doc, &set)? - }; + let (pages, blank) = pages(doc, &set, saving)?; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } - // The artboards' background colors, behind the art (the editing data keeps them as colors). - let filled = super::export::with_artboard_fills(doc, p, pages.iter().filter_map(|(b, ..)| *b)).map_err(|e| bad(C, e))?; + // The file Save writes alone becomes the document's own, so it carries every artboard. + let whole = saving && pages.len() == 1; + // The artboards' background colors, behind the art (the editing data keeps them as colors); + // a page on the first artboard because no art shows has that artboard's color. + let fills = pages.iter().filter_map(|(b, ..)| *b).chain(blank.then_some(0)); + let filled = super::export::with_artboard_fills(doc, p, fills).map_err(|e| bad(C, e))?; let doc = &*filled; let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect(); let expanded = expand_transparent_tile_images(doc, &set.flatten)?; @@ -358,9 +370,9 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let o = EpsOptions { region, origin, - title: doc.title.clone(), + title: doc.info_title().to_owned(), created, - native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), + 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/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index b04104f70f..d5137c8b1a 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -1,6 +1,7 @@ use super::super::unique_name; use super::{ - Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported, + Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, hpgl, json, metafile, native, pdf, screens, svg, text, + unsupported, }; /// One option a format's encoder reads from the export params. @@ -479,8 +480,17 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, - // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them. - reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false), + // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths and written as pen moves. + Format { + id: "hpgl", + label: "HP-GL", + extensions: &["plt", "hpgl", "hgl", "hpg"], + mime: "application/vnd.hp-hpgl", + read: true, + write: true, + raster: false, + options: hpgl::OPTIONS, + }, Format { id: "tga", label: "Targa", @@ -920,13 +930,20 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String { /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + is_own_title(doc.info_title()) +} + +/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives? +pub(crate) fn is_own_title(title: &str) -> bool { + !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> { let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; let mut d = doc.clone(); d.title = file_stem(path); + // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it. + d.metadata.title = None; Some(d) } diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs new file mode 100644 index 0000000000..ff6e63fbd7 --- /dev/null +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -0,0 +1,126 @@ +//! HP-GL for every path that writes plot files (`vectorcraft_cad::hpgl`): `document.export {format: +//! hpgl}` and Export As (one plot of an artboard, or one per artboard with Use Artboards), with the +//! options parsed from the params. + +use serde_json::Value; +use vectorcraft_cad::hpgl::write::{DEFAULT_TOLERANCE, MAX_PENS, TOLERANCE_RANGE}; +use vectorcraft_cad::hpgl::{HpglOptions, Origin, Version}; +use vectorcraft_doc::Document; + +use super::super::{Result, bad, number_or, switch_or, text_param}; +use super::{ArtboardPick, Encoded, FormatOption}; + +const C: &str = "document.export"; + +/// The options `document.formats` lists for HP-GL. +pub(super) const OPTIONS: &[FormatOption] = &[ + super::ARTBOARD, + super::ARTBOARDS, + super::RANGE, + super::USE_ARTBOARDS, + FormatOption { + name: "hpglVersion", + ty: "string", + default: "\"hpgl\"", + description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", + }, + FormatOption { + name: "curveTolerance", + ty: "number", + default: "0.1", + description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)", + }, + FormatOption { + name: "pens", + ty: "array", + default: "null", + description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export", + }, + FormatOption { + name: "origin", + ty: "string", + default: "\"bottomLeft\"", + description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)", + }, + FormatOption { + name: "sortPaths", + ty: "boolean", + default: "false", + description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order", + }, + FormatOption { + name: "insideFirst", + ty: "boolean", + default: "false", + description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next", + }, +]; + +/// The HP-GL options in `p` (the region is set per plot). +fn parse(p: &Value) -> Result<HpglOptions> { + let mut o = HpglOptions::default(); + if let Some(s) = text_param(p, "hpglVersion", C)? { + let key = "hpglVersion"; + o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?; + } + let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?; + if !TOLERANCE_RANGE.contains(&x) { + let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end()); + return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})"))); + } + o.tolerance = x; + match p.get("pens") { + None | Some(Value::Null) => {} + Some(v) => { + let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect()); + o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?; + } + } + if let Some(s) = text_param(p, "origin", C)? { + let key = "origin"; + o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?; + } + o.sort = switch_or(p, "sortPaths", false, C)?; + o.inside_first = switch_or(p, "insideFirst", false, C)?; + Ok(o) +} + +/// A `#rrggbb` color. +fn hex(v: &Value) -> Option<[u8; 3]> { + let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?; + let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + Some([byte(0)?, byte(2)?, byte(4)?]) +} + +/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with +/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all. +pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> { + let opts = parse(p)?; + let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?; + let pick: ArtboardPick = super::encode::options(f, p)?; + let n = doc.artboards.len(); + let boards = match use_artboards { + Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()), + _ => vec![pick.one(n).map_err(|e| bad(C, e))?], + }; + let regions = boards + .iter() + .map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))) + .collect::<Result<Vec<_>>>()?; + let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, ®ions).map_err(|e| bad(C, e))?; + let mut enc = Encoded::default(); + for (b, out) in boards.into_iter().zip(plots) { + enc.files.push((Some(b), out.bytes)); + for w in out.warnings { + if !enc.warnings.contains(&w) { + enc.warnings.push(w); + } + } + } + if enc.files.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); + enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); + Ok(enc) +} diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index a197363b23..1fd3e10a9f 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -145,7 +145,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } } } - let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text }; + let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text }; let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..3040cfc43f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; +use super::super::fileinfo::imported_title; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name}; @@ -550,6 +551,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } + // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` + // or Info `Title`, which the readers put in File Info) is separate from the document's name. + // Restored File Info is preserved exactly; newly imported SVG candidates and a restored + // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626). + if !restored && matches!(format.id, "svg" | "svgz") { + doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title); + } + let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); + doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title)); // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { @@ -678,6 +688,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St if template { doc.template = false; doc.title = s.next_untitled(); + doc.metadata.title = None; } // A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start // closed, its top-level layers as its groups (#1475). Ours carry their own. @@ -711,7 +722,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St st.import_losses = losses; st.links_from = read_from; let opened = &s.documents()[index]; - let (title, file_info_title) = (opened.title(), opened.doc.title.clone()); + let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string()); Ok(super::merge( json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json(), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..5d7783665b 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -731,7 +731,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res // With the artboards' background colors, as the pages draw them. let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; let o = vectorcraft_eps::AiEditingOptions { - title: doc.title.clone(), + title: doc.info_title().to_owned(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; @@ -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")); } @@ -883,7 +885,13 @@ impl SaveJob { /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: /// [path…], linked?: [path…]}`. - pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { + pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { + self.write_unless(|_| None, write) + } + + /// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in + /// order: when `stop` returns a value, nothing is written and that value is returned. + pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; let warnings = std::mem::take(&mut enc.warnings); @@ -897,6 +905,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) { + return Ok(stopped); + } for (p, bytes) in &files { write(p, bytes)?; } @@ -1032,11 +1043,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let out = job.write_streamed(&mut super::write_file_with)?; + let mut out = job.write_streamed(&mut super::write_file_with)?; job.complete(s, uid); + leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) } +/// The reply `out` of a save with the `path` param `given`, as the request being served may see it +/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is +/// left out, and the `files` and `linked` written beside it are named by their file names, when they +/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it +/// is outside them. Without roots in force `out` is unchanged. +pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) { + use super::access::{shown, shown_path}; + + if crate::file_access::current().is_none() { + return; + } + let Some(o) = out.as_object_mut() else { return }; + if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) { + o.remove("folder"); + } + // Every path the reply names then is the one the client gave, or beside it. + if given.is_some_and(|g| !g.is_empty()) { + return; + } + if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) { + o.remove("path"); + } + for key in ["files", "linked"] { + if let Some(Value::Array(rows)) = o.get_mut(key) { + for row in rows { + if let Value::String(p) = row + && !shown(p) + { + *row = json!(shown_path(p)); + } + } + } + } +} + fn can_revert(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; match (&st.path, st.is_dirty()) { @@ -1092,6 +1139,25 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> { } } } + // 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::<Vec<usize>>(a.clone()).ok()) + .or_else(|| m.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).map(|i| vec![i])) + .filter(|b| !b.is_empty()); + if range.is_some() || boards.is_some() { + v["options"]["useArtboards"]["value"] = json!(true); + if let (None, Some(boards)) = (range, boards) { + v["options"]["range"]["value"] = json!(range_text(&boards)); + } + } + } v["saveFormats"] = SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); Ok(v) @@ -1104,7 +1170,7 @@ pub(super) fn specs() -> Vec<CommandSpec> { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -1158,7 +1224,7 @@ pub(super) fn specs() -> Vec<CommandSpec> { "Format Options", [], None, - "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default", + "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default (EPS: useArtboards is true when the last save named artboards without it, and range then names them)", always, format_options ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 56c0d808a5..b48aef68c3 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -196,7 +196,7 @@ pub(super) fn name_without_device(name: &str) -> String { pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"]; /// The writable formats a format row can't name. -pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"]; +pub(crate) const UNWRITTEN_FORMATS: [&str; 6] = ["template", "txt", "dxf", "emf", "wmf", "hpgl"]; /// One format row: what to write, at which size, with which suffix, into which sub-folder. struct Row { @@ -644,12 +644,86 @@ impl Output { /// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with /// the params remembered in the document (`document.exportSettings`). pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { + // A folder the client gave, as against the one its `exportPreset` keeps. + let given = p.get("folder").is_some(); let p = with_preset(s, p)?; - let r = export(s, &p)?; + let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; + let mut r = export(s, &p).map_err(|e| match hidden { + Some(folder) => leave_out_folder_from_error(e, folder), + None => e, + })?; remember(s, &p); + if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { + leave_out_unreadable_folder(&mut r, folder); + } Ok(r) } +/// An error of an export into `folder`, a folder outside the read roots that the client did not +/// give: `folder` as the path the error is about names it by its own name +/// ([`super::access::shown_path`]), and the rest of the message stays. +fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { + let name = super::access::shown_path(folder); + match e { + EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, + e => e, + } +} + +/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start +/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or +/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message +/// names stays whole, even one inside `folder`. +fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(folder) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + +/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being +/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's +/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), +/// as the reply of an export to a ZIP names them. +fn leave_out_unreadable_folder(r: &mut Value, folder: &str) { + if super::access::shown(folder) { + return; + } + let Some(o) = r.as_object_mut() else { return }; + o.remove("path"); + let inside = format!("{folder}/"); + if let Some(Value::Array(files)) = o.get_mut("files") { + for file in files { + // A ZIP's files are named relative to it already. + if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) { + *file = json!(name); + } + } + } +} + +/// Export settings kept in the document or a preset, as the request being served may see them: a +/// `folder` outside the read roots ([`super::access::shown`]) is left out. +fn leave_out_unreadable_settings_folder(mut settings: Map<String, Value>) -> Map<String, Value> { + if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) { + settings.remove("folder"); + } + settings +} + // ---------- export presets ---------- /// A saved batch export: what to write and how to name and sort it, to use again @@ -727,7 +801,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value { } pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> { - Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) })) + let saved: Vec<ScreenExportPreset> = s + .prefs + .screen_export_presets + .iter() + .map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) }) + .collect(); + Ok(json!({ "presets": preset_list(&saved) })) } /// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would @@ -831,6 +911,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> { return Err(too_many()); } let count = presets.len(); + let presets: Vec<ScreenExportPreset> = + presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect(); let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?; if text.len() > MAX_PRESET_FILE_BYTES { return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes"))); @@ -1388,12 +1470,31 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map<String, Value>) /// `document.exportSettings`: the Export for Screens settings the document last exported with. pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> { - Ok(json!({ "settings": s.doc()?.doc.export_settings })) + Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) })) } #[cfg(test)] mod tests { - use super::{Output, ScreenSize, safe_file_name}; + use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + + /// A hidden export folder is named by its name where a message is about it, and a root that + /// starts with the same text, or a longer path, stays whole. + #[test] + fn a_hidden_folder_is_named_by_its_name_in_a_message() { + let f = "/work/out"; + let cases = [ + ( + "automation path rejected: outside the write root /work/out/in: /work/out", + "automation path rejected: outside the write root /work/out/in: out", + ), + ("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"), + ("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"), + ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), + ]; + for (msg, want) in cases { + assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + } + } /// Files given one name are numbered in turn, each numbering going on from the last one (so /// thousands of them don't each try every number used before), and a file asking again for diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 6236d281e2..c90e077eb4 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject { } } +/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\` +/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole. +#[test] +fn a_hidden_path_shows_as_its_file_name_on_every_platform() { + let windows = "C:\\Users\\someone\\art\\red.png"; + crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || { + for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] { + assert_eq!(shown_path(path), name, "{path}"); + } + }); + assert_eq!(shown_path(windows), windows); +} + #[test] fn opens_every_readable_format() { let mut s = session(200.0, 100.0, 1); @@ -418,6 +431,7 @@ fn export_extensions_name_every_writable_format() { "eps", "emf", "wmf", + "plt", "tga", "psd" ] @@ -453,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() { "eps", "emf", "wmf", + "hpgl", "tga", "psd" ] diff --git a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs index 9a7684686a..371cdc05ab 100644 --- a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs +++ b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs @@ -109,23 +109,25 @@ fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() { } } +/// Does PostScript `t` paint the only red the document has, the first artboard's color? RGB, or +/// CMYK PostScript's (its values depend on the color conversion the platform offers, so only its +/// hue is checked). +fn red(t: &str) -> bool { + t.lines().any(|l| { + let mut w: Vec<&str> = l.split_whitespace().collect(); + let Some(op) = w.pop() else { return false }; + let v: Vec<f64> = w.iter().filter_map(|n| n.parse().ok()).collect(); + match (op, v.as_slice()) { + ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, + ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, + _ => false, + } + }) +} + #[test] fn eps_and_postscript_print_draw_the_color() { let mut s = session(); - // The only red the document has, the first artboard's color: RGB, or CMYK PostScript's (its - // values depend on the color conversion the platform offers, so only its hue is checked). - let red = |t: &str| { - t.lines().any(|l| { - let mut w: Vec<&str> = l.split_whitespace().collect(); - let Some(op) = w.pop() else { return false }; - let v: Vec<f64> = w.iter().filter_map(|n| n.parse().ok()).collect(); - match (op, v.as_slice()) { - ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, - ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, - _ => false, - } - }) - }; let eps = |s: &mut Session, more: Value| { String::from_utf8_lossy(&file(s, with(json!({"format": "eps", "useArtboards": true, "artboard": 0}), &more))).into_owned() }; @@ -138,6 +140,22 @@ fn eps_and_postscript_print_draw_the_color() { assert!(!red(&ps(&mut s, json!({"includeArtboardFill": false}))), "print without it"); } +/// EPS Save draws the color on an artboard's page: with Use Artboards, or on the first artboard +/// when no art shows. The box of the visible art, as Export's, has none. +#[test] +fn eps_save_draws_the_color_on_an_artboard_page() { + let mut s = session(); + let save = |s: &mut Session, more: Value| { + let options = with(json!({"previewFormat": "none", "thumbnails": false}), &more); + String::from_utf8_lossy(&b64(&s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap())).into_owned() + }; + assert!(!red(&save(&mut s, json!({}))), "the art's box"); + assert!(red(&save(&mut s, json!({"useArtboards": true, "range": "1"}))), "the artboard's page"); + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(red(&save(&mut s, json!({}))), "the first artboard when no art shows"); +} + #[test] fn export_for_screens_includes_the_fill_unless_unchecked() { let mut s = session(); diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 8497676836..54839edda4 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() { true ) ); + assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches"); + let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap(); + assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it"); + let custom = dxf::options(&json!({})).unwrap(); + assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both"); assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); assert!(e.contains("custom or cnc"), "{e}"); @@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill +/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves +/// out both, and the options given override it. +#[test] +fn placed_images_and_fill_hatches_can_be_left_out() { + let mut s = super::tests_svg::image_session(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap(); + let counts = |r: &Value| { + let e = entities(&text(r)); + let n = |k: &str| e.iter().filter(|x| *x == k).count(); + (n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len)) + }; + let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w))); + let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + assert_eq!(counts(&all), (1, 1, 1, 1)); + let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap(); + assert_eq!(counts(&r), (1, 1, 0, 0)); + assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap(); + assert_eq!(counts(&r), (1, 0, 0, 0)); + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + // With a path, no image file is written next to the drawing. + let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cut.dxf").to_string_lossy().to_string(); + let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap(); + assert!(r.get("linked").is_none(), "{r}"); + let files: Vec<String> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(files, ["cut.dxf"]); + let _ = std::fs::remove_dir_all(dir); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. @@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() { json!({"rasterFormat": "gif"}), json!({"preserve": "both"}), json!({"alterPaths": "yes"}), + json!({"includeImages": "no"}), + json!({"includeHatches": 0}), ] { let e = s.execute("document.exportDxf", &p).unwrap_err().to_string(); assert!(e.contains("DXF"), "{p}: {e}"); @@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() { "scaleLineweights", "colors", "rasterFormat", + "includeImages", + "includeHatches", "preserve", "alterPaths", "outlineText", diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 271f7b86e2..f4767fd81d 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -1,8 +1,9 @@ //! HP-GL plotter files through the commands: `document.open` by extension or by content, File › -//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't -//! HP-GL refused with a message. +//! Place as one group of paths, the dialogs' filters and the format list, files that aren't +//! HP-GL refused with a message, and `document.export` of one artboard, each or the art, with its +//! options checked. -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::*; @@ -100,5 +101,131 @@ fn the_dialogs_and_the_format_list_name_hpgl() { assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); let r = Session::new().execute("document.formats", &json!({})).unwrap(); let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); - assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false))); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false))); + for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] { + assert!(f["options"].get(o).is_some(), "{o}: {f}"); + } + assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl"); + assert_eq!(writable_format(None, Some("x/part.hpgl")).unwrap().id, "hpgl"); +} + +fn bytes(v: &Value) -> Vec<u8> { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red. +fn two_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap(); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap(); + s +} + +#[test] +fn export_writes_a_plot_of_one_artboard_or_each() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap(); + assert_eq!(r["format"], "hpgl"); + // The first artboard's square, 1016 units to the inch from its bottom-left corner. + assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;"); + let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap(); + assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1"); + assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r)); + let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}"); + // One pen numbering for every file: the red square is pen 2 in the second. + assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;"); + // The bounds of the art: both squares, the second pen red, read back as two paths. + let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap()); + assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art)); + let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap(); + assert_eq!(back["format"], "hpgl", "{back}"); + // One file holds one artboard. + assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err()); +} + +/// A plot read back opens with the same squares: as wide as the art drew them, a point at most +/// apart from the 30 pt sides plus the 0.35 mm pen. +#[test] +fn a_plot_reads_back_as_its_art() { + let mut s = two_boards(); + let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap()); + let mut back = Session::new(); + back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap(); + let doc = &back.doc().unwrap().doc; + let mut paths = 0; + doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(paths, 2); + let board = doc.artboards[0].rect; + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + let pen = 0.35 * 72.0 / 25.4; + assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}"); + assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}"); +} + +#[test] +fn export_options_are_checked() { + let mut s = two_boards(); + let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string(); + let cases = [ + (json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"), + (json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"), + (json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"), + (json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"), + (json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"), + (json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"), + (json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"), + ]; + for (p, want) in cases { + let e = refused(&mut s, p.clone()); + assert!(e.contains(want), "{p}: {e}"); + } + let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect(); + assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`")); + // Given options take effect: the center as the origin, a pen table (black is pen 2), and the + // square starting at its corner nearest the origin. + let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true}); + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;"); +} + +/// `insideFirst` draws the square inside the other one first, though it was made last. +#[test] +fn inside_first_draws_the_inner_square_first() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap(); + let starts = |s: &mut Session, p: Value| { + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + (plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap()) + }; + let (outer, inner) = starts(&mut s, json!({"format": "hpgl"})); + assert!(outer < inner); + let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true})); + assert!(inner < outer); +} + +/// Export Selection plots the selected square alone (the red one, made last), from the +/// bottom-left corner of its bounds. +#[test] +fn export_selection_writes_a_plot_of_the_selection() { + let mut s = two_boards(); + let r = s.execute("document.exportSelection", &json!({"format": "hpgl"})).unwrap(); + assert_eq!(r["format"], "hpgl"); + let plot = String::from_utf8(bytes(&r)).unwrap(); + assert_eq!(plot.matches("PD").count(), 1, "{plot}"); + assert!(plot.starts_with("IN;PU;SP1;PU7,430;PD"), "{plot}"); } diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index b0dcd368ec..86ee164998 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -667,6 +667,118 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() { assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder"); } +/// An export into the folder an export preset keeps, for a client whose read roots don't hold that +/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder +/// inside the read roots, and one the client gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| confine(roots.as_ref(), || export(s, p)); + + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"})); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(out.join("1x/Artboard-1.png").is_file()); + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true})); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // Inside the read roots, or given by the client: whole. + assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")])); + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true})); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); +} + +/// An export into the folder an export preset keeps, refused because that folder is outside the +/// client's read and write roots: the error names the folder by its name only. A folder the client +/// gives stays whole in the error. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-refused"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string(); + + let e = run(json!({"exportPreset": "Kept"})); + assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}"); + let e = run(json!({"exportPreset": "Kept", "folder": folder})); + assert!(e.contains(&folder), "{e}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // A preset folder that holds the write root: the root stays whole in the error. + let parent = tmp.0.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": parent}); + s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); + let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); + let root = inside.display().to_string(); + let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); + assert!(e.contains(&format!("root {root}: {name}")), "{e}"); +} + +/// The export settings the document keeps and the saved presets, read by a client whose read roots +/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and +/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. +/// Inside the read roots the folder stays. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn kept_settings_leave_out_a_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("settings-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + export(&mut s, json!({"exportPreset": "Kept"})); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap(); + let ask = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, cmd: &str, p: Value| { + confine(roots.as_ref(), || s.execute(cmd, &p).unwrap()) + }; + let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone(); + + let r = ask(&mut s, &confined, "document.exportSettings", json!({})); + assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}"); + let r = ask(&mut s, &confined, "document.exportPresets.list", json!({})); + assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}"); + let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]})); + let data = r["data"].as_str().unwrap(); + assert!(!data.contains(&folder) && data.contains("svg"), "{data}"); + let file = inside.join("kept.vcexportpresets"); + let path = file.to_string_lossy().replace('\\', "/"); + ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path})); + assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder)); + + // Inside the read roots: whole. + assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder)); + assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder)); + assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder)); +} + #[test] fn format_settings_apply_to_their_rows_and_rows_win() { let mut s = session(1); diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index 2f69a9df39..d27809fa78 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -109,6 +109,40 @@ fn unlinked(g: &Gradient) -> Gradient { g } +/// Give each saved preset in `saved` a name no built-in preset or earlier saved one has (any case): +/// a clash becomes the first free "<name> N", N from 2. Returns the renames as (old, new). +pub(crate) fn rename_clashing_presets(saved: &mut [GradientPreset]) -> Vec<(String, String)> { + if saved.is_empty() { + return vec![]; + } + let builtin: std::collections::HashSet<String> = builtin_presets().map(|e| e.name.to_ascii_lowercase()).collect(); + let mut taken: std::collections::HashSet<String> = + builtin.iter().cloned().chain(saved.iter().map(|p| p.name.trim().to_ascii_lowercase())).collect(); + let mut kept = std::collections::HashSet::new(); + // The next suffix to try for each name, so many presets with one name take linear time. + let mut next: std::collections::HashMap<String, u64> = std::collections::HashMap::new(); + let mut renamed = vec![]; + for p in saved { + let key = p.name.trim().to_ascii_lowercase(); + if !builtin.contains(&key) && kept.insert(key.clone()) { + continue; + } + let base = p.name.trim(); + let n = next.entry(key).or_insert(2); + let name = loop { + let candidate = format!("{base} {n}"); + *n += 1; + if !taken.contains(&candidate.to_ascii_lowercase()) { + break candidate; + } + }; + taken.insert(name.to_ascii_lowercase()); + kept.insert(name.to_ascii_lowercase()); + renamed.push((std::mem::replace(&mut p.name, name.clone()), name)); + } + renamed +} + impl Session { /// The Gradient panel's presets: the built-in ones by library, then the saved ones. pub fn gradient_presets(&self) -> Vec<GradientPresetEntry> { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 49b1015bf4..593d325460 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, id_param, ids_param, index, ok, str_param, switch_or, + has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> { /// The Layers panel's target circle. fn target(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.target"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; if !st.doc.is_editable(id) { @@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> { fn set_current(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.setCurrent"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc_mut()?; let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?; st.active_layer = Some(layer); @@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool { /// The selection column: select a row's art. fn select_art(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.selectAll"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let add = switch_or(p, "add", false, C)?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; @@ -747,9 +747,12 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = match ids_param(p, "ids", C)? { + Some(ids) => ids, + None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?], + }; ids.truncate(MAX_ROWS); - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { "above" => Place::Above, "below" => Place::Below, @@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> { fn move_node(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "node.move"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let parent = id_param(p, "parent"); + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; + let parent = object_param(p, "parent", C)?; let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index); let d = &s.doc()?.doc; let n = d.node(id).ok_or(EngineError::NoNode(id))?; diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 464f241159..618865cede 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,7 +21,7 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi, - has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param, + has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param, }; mod blend; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 7d7c21227a..d47e81befd 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -1,5 +1,7 @@ use std::collections::BTreeSet; +use vectorcraft_doc::live::BlendSpineMode; + use super::common::{ DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, spacing_param, starts_param, wrap, @@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc<Node>], old_spec: &BlendSpec, k spec.spine = Some(path); } +/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine. +fn gives_easing(p: &Value) -> bool { + ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) +} + +/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null. +fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result<BlendSpineMode> { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))), + } +} + pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.blend.make"; let roots = roots_param(s, p)?; @@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> { } let starts = starts_param(p, &roots)?; let base = first_blend(&s.doc()?.doc, &roots); + let defaults = s.prefs.blend_options.unwrap_or_default(); + // Create Spine applies to a new blend; one taking more keys keeps whether it has a spine. + let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?; let cur = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, - _ => s.prefs.blend_options.unwrap_or_default(), + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults }, + _ => defaults, }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); - let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), + let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine), + _ => (Default::default(), 0.0, !create_spine.creates_spine()), }; + if no_spine && gives_easing(p) { + return Err(bad(C, "the blend has no spine")); + } let ease = ease_param(p, C, ease0)?; let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { @@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -303,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> { if anchors.contains(&index) { return Err(bad(C, "a key object sits on that point")); } - if sp.anchors.len() <= 2 { + // A closed spine pinned open keeps a copy of its first point at its end (#6809), so the + // points left must be two apart, not only two in number. + let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p); + let first = rest.next(); + if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) { return Err(bad(C, "a spine keeps at least two points")); } sp.anchors.remove(index); @@ -346,9 +371,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> { // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; - s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d }); return Ok(json!({ "defaults": true })); }; + if gives_easing(p) && any_without_spine(s) { + return Err(bad(C, "the blend has no spine")); + } let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); let ease = ease_param(p, C, cur.ease)?; @@ -384,6 +412,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> { v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>()); + v["hasSpine"] = json!(!spec.no_spine); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) && let Some((path, anchors)) = live::blend_spine(children, &spec) && let Some(sp) = path.subpaths.first() @@ -403,6 +432,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> { }) } +/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads +/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs. +pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "object.blend.createSpine"; + let mut d = s.prefs.blend_options.unwrap_or_default(); + let mode = spine_mode_param(p, C, "mode", d.create_spine)?; + if mode != d.create_spine { + d.create_spine = mode; + s.prefs.blend_options = Some(d); + } + Ok(json!({ "mode": mode.id() })) +} + pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result<Value> { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { @@ -507,6 +549,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> { let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + // A blend made without a spine gets this one. + spec.no_spine = false; spec.spine = Some(spine); spec.key_anchors.clear(); keys_to_spine(children, spec); @@ -517,7 +561,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> { ok() } +/// Is any selected blend one made without a spine? +fn any_without_spine(s: &Session) -> bool { + let Ok(st) = s.doc() else { return false }; + selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) +} + pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> { + if any_without_spine(s) { + return Err(bad("object.blend.reverseSpine", "the blend has no spine")); + } edit_blends(s, "Reverse Spine", |children, spec| { if spec.spine.is_some() && pin(children, spec) { // The keys keep their anchors, which now count from the other end. diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869d..5480f7ae58 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ - blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, - spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, + blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}", has_doc, blend_make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", + "{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> { always, blend_options ), + cmd!( + "object.blend.createSpine", + "Create Spine", + [], + None, + "{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}", + always, + blend_create_spine + ), cmd!( "object.blend.expand", "Expand", @@ -54,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> { "Replace Spine", ["Object", "Blend"], None, - "{} use the selected path as the spine of the selected blend (the path is consumed)", + "{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one", has_blend, blend_replace_spine ), @@ -63,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reverse Spine", ["Object", "Blend"], None, - "{} reverse the order of the key objects along the spine", + "{} reverse the order of the key objects along the spine; an error when a selected blend has no spine", has_blend, blend_reverse_spine ), @@ -72,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..3412023280 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr use super::mesh::{handle_param, index_param, whole}; use super::{ Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, - PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -419,7 +419,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 70c2d9f348..187d2149f2 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec use super::envelope::{outline_of, rows_cols}; use super::{ Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, - check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, + check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param, }; // ---------- Gradient mesh ---------- @@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh /// envelope's grid too (see [`edit_grid`]). fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> { - let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) } @@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20; /// points}…]}`. Each mesh comes once, with its points in order and without repeats. fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> { let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> { - let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; let points = match q.get("points") { None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), Some(Value::Array(v)) if v.len() <= MAX_POINTS => v diff --git a/crates/engine/src/cmd/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<Value> { let symbols = d.symbol_boxes(); let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect(); let mut converted = vec![]; + // The highest artboard id the document has, kept up to date as artboards are added. + let mut top = d.artboards.iter().map(|a| a.id).max(); for (id, b) in boxes { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } // Named one past the highest artboard id the document has, whatever id it is given. - let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1)); - let aid = d.next_artboard_id(); + let number = top.map_or(1, |m| m.saturating_add(1)); + let aid = d.next_artboard_id_above(top); + top = top.max(Some(aid)); d.artboards.push(vectorcraft_doc::Artboard { id: aid, name: format!("Artboard {number}"), diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index eb019a88e9..5c66ec8442 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index ee6d7e3c89..8d6f53d55b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; -use super::fileio::{create_dir, write_file}; +use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file}; use super::links::LinkBase; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> { "Package", [], None, - "{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder", + "{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder. The warnings and the report name a file outside the automation read roots by its file name", has_doc, package )] @@ -178,8 +178,10 @@ impl<'a> Collector<'a> { /// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the /// copy of `source` finds it only by its name, beside it. + /// `path` is the link's path as the warning names it. fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) { if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) { + let source = shown_path(source); let message = format!( "{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview" ); @@ -207,11 +209,13 @@ impl<'a> Collector<'a> { let section = self.sections.len(); self.sections.push(vec![]); let mut lines = vec![]; - // The packaged document's sections read as before; a placed document's name its file. + // The packaged document's sections read as before; a placed document's name its file. The + // report and the warnings name a file outside a confined request's read roots by its name. + let shown_source = shown_path(source); let heading = |lines: &mut Vec<String>, title: &str| { lines.push(title.into()); if depth > 0 { - lines.push(format!("Document: {source}")); + lines.push(format!("Document: {shown_source}")); } }; // A file's saved path is only meaningful inside its own document. @@ -243,17 +247,21 @@ impl<'a> Collector<'a> { heading(&mut lines, "LINKED FILES"); for file in linked_files(doc) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); + // The stored path, whole when the place it names (read from this document's folder) is + // inside the read roots. A file found elsewhere does not show a stored path outside them. + let resolved = document_path(&file.path, Some(source)).ok(); + let shown = shown_link(&file.path, resolved.as_deref()).into_owned(); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A // cyclic reference cannot have a final content hash until both files // have been written, so leave its size/hash unspecified, not stale. - self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path)); + self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle")); local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); continue; } if let Some(existing) = self.copies.get(found_path).cloned() { if by_name { - self.renamed(&mut lines, source, &file.path, &file.name, &existing.name); + self.renamed(&mut lines, source, &shown, &file.name, &existing.name); } // Another document may place other pages of a PDF copied already. if self.opts.copy_fonts @@ -265,8 +273,8 @@ impl<'a> Collector<'a> { continue; } let Some(mut bytes) = file.bytes else { - lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); - self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) }); + lines.push(format!("{}: not found ({shown}), not copied", file.name)); + self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) }); continue; }; if self.copies.len() >= 10_000 { @@ -277,7 +285,7 @@ impl<'a> Collector<'a> { let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); if by_name { - self.renamed(&mut lines, source, &file.path, &file.name, &destination_file); + self.renamed(&mut lines, source, &shown, &file.name, &destination_file); } // A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file // read from its editing data links are copied beside it. @@ -285,7 +293,7 @@ impl<'a> Collector<'a> { let placed_file = pages.is_some(); if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links")); } else { self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?; } @@ -296,7 +304,7 @@ impl<'a> Collector<'a> { } if placed_file && super::place::document::is_native(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links")); } else { match vectorcraft_format::load_file(&bytes) { Ok(saved) => { @@ -320,8 +328,7 @@ impl<'a> Collector<'a> { Err(e) => self.warn( &mut lines, format!( - "{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked", - file.path + "{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked" ), ), } @@ -329,7 +336,7 @@ impl<'a> Collector<'a> { } Err(e) => self.warn( &mut lines, - format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path), + format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"), ), } } @@ -337,7 +344,7 @@ impl<'a> Collector<'a> { let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) }; self.copies.insert(found_path.to_string(), copy.clone()); local.insert(file.path.clone(), copy); - lines.push(format!("{} → {destination_file}", file.path)); + lines.push(format!("{shown} → {destination_file}")); self.entries.push((destination_file, bytes)); } lines.push(String::new()); @@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res // The report. if let Some(info) = info { let report_name = free_name(&format!("{stem} Report.txt"), &mut taken); - let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n"); + let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path)); lines.iter().for_each(|l| text.push_str(&format!("{l}\n"))); text.push_str(&info); entries.push((report_name, text.into_bytes())); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b8bdd24d05..83a0655284 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -482,6 +482,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "hyphenate", "hyphenateCapitalized", "romanHangingPunctuation", + "koreanLineBreakAnywhere", ], C, )?; @@ -552,6 +553,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "fontAxes", "burasagari", "romanHangingPunctuation", + "koreanLineBreakAnywhere", "kashida", "composer", ]; @@ -879,6 +881,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = flag("romanHangingPunctuation") { para.roman_hanging = v; } + if let Some(v) = flag("koreanLineBreakAnywhere") { + para.korean_line_break_anywhere = v; + } if let Some(v) = kashida { para.kashida = v; } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 91da35e51b..c05493e964 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, + has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< } fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; + let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { @@ -206,7 +206,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> { /// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only /// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. fn close(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { None => Ok(None), @@ -303,7 +303,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } fn set_handle(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; + let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); @@ -321,7 +321,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> { /// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point /// tool's Shift-drag). fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?; + let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); @@ -341,7 +341,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> { } fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?; + let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; let data = PathData::new( subs.iter() @@ -1562,7 +1562,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz } fn reshape(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; + let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?; let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); @@ -1684,7 +1684,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> { } fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; + let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 011dfc131c..8fd2a0ea5e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary { } /// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order. -pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = - &[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }]; +pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ + BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, + BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, +]; /// The library category of the built-in pattern libraries. pub const CATEGORY: &str = "patterns"; @@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) { add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]); } +/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same +/// marks every time it is made. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A number from `lo` up to, but not including, `hi`. + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo) + } +} + +/// A round dot of radius `r` at (`x`, `y`). +fn dot(x: f64, y: f64, r: f64) -> PathData { + shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r)) +} + +/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it. +fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData { + use std::f64::consts::TAU; + let n = 4 + (rng.next() % 4) as usize; + let turn = rng.range(0.0, TAU); + let points: Vec<(f64, f64)> = (0..n) + .map(|k| { + let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64; + let reach = r * rng.range(0.55, 1.0); + (x + reach * a.cos(), y + reach * a.sin()) + }) + .collect(); + polygon(&points) +} + +/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle. +fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData { + let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos(); + let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0); + polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)]) +} + +/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from +/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling +/// draws the part outside the tile on the next tile, so the tiles join without a seam. +fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) { + let mut rng = SplitMix64(seed); + let marks = (0..count) + .map(|_| { + let (x, y) = (rng.range(0.0, size), rng.range(0.0, size)); + mark(&mut rng, x, y) + }) + .collect(); + add(d, name, size, size, TileType::Grid, marks); +} + +/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on +/// a clear background, each drawn from its own fixed seed. +fn grit_textures(d: &mut Document) { + grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35))); + grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| { + let r = rng.range(0.45, 1.1); + fleck(rng, x, y, r) + }); + grit(d, "Sand", 24.0, 3, 280, |rng, x, y| { + let r = rng.range(0.2, 0.45); + fleck(rng, x, y, r) + }); + grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9))); + // Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center. + let mut rng = SplitMix64(5); + let (size, cells) = (24.0, 12); + let step = size / f64::from(cells); + let dots = (0..cells) + .flat_map(|row| (0..cells).map(move |col| (col, row))) + .map(|(col, row)| { + let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step; + let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step; + dot(x, y, rng.range(0.3, 0.45)) + }) + .collect(); + add(d, "Stipple", size, size, TileType::Grid, dots); + grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| { + let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35)); + scratch(rng, x, y, len, width) + }); + grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| { + let r = rng.range(0.35, 0.8); + fleck(rng, x, y, r) + }); + grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| { + let r = rng.range(0.2, 0.5); + fleck(rng, x, y, r) + }); +} + #[cfg(test)] mod tests { use super::*; @@ -168,4 +270,60 @@ mod tests { assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}"); } } + + fn grit_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + grit_textures(&mut d); + d + } + + #[test] + fn grit_textures_draw_the_same_marks_every_time() { + let (a, b) = (grit_doc(), grit_doc()); + assert_eq!(a.patterns.len(), 8); + assert_eq!(a.patterns, b.patterns); + } + + /// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile + /// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile + /// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw + /// twice in one place. + #[test] + fn grit_tiles_join_without_a_seam() { + let mut crossing = 0; + for p in &grit_doc().patterns { + let (w, h) = (p.width(), p.height()); + assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len()); + let bounds: Vec<Rect> = p + .art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)), + _ => panic!("{}: a mark that is not a path", p.name), + }) + .collect(); + let near = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + let band = p.tile.inflate(w / 4.0, h / 4.0); + for b in &bounds { + assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name); + // The tile past each edge the mark crosses draws this tile's art. + let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0))); + for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] { + let next = p.tile + kurbo::Vec2::new(x * w, y * h); + assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name); + } + crossing += usize::from(dx != 0.0 || dy != 0.0); + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + assert!(crossing > 0, "no mark reaches past an edge"); + } } diff --git a/crates/engine/src/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<Value> { // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there - // was none, no module goes with the grant, and the grant stops counting for this session. A - // grants file that is too large or doesn't hold a list approves nothing, and the new module - // stays; one that can't be opened or read may approve the old bytes once it reads again. + // was none, no module goes with the grant, and the grant stops counting for every session in + // the process attached to the folder. A grants file that is too large or doesn't hold a list + // approves nothing, and the new module stays; one that can't be opened or read may approve + // the old bytes once it reads again. + let writes = s.grant_writes(); if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); stored = !back && 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 55566c3d9b..551588f489 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -110,7 +110,9 @@ pub(super) const ALLOWED: &[&str] = &[ "brush.move", "brush.new", "brush.options", + "brush.placeArt", "brush.remove", + "brush.replaceArt", "brush.selectionArt", "brush.setCurrent", "brush.unused", @@ -678,6 +680,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<CommandSpec> { vec![ - cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get), + cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get), cmd!( query "prefs.set", "Set Preference", @@ -776,7 +780,7 @@ pub fn specs() -> Vec<CommandSpec> { set ), cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset), - cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list), + cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list), cmd!( query "prefs.export", "Export Preferences", @@ -882,8 +886,42 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "imported": values.len() })) } +/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks. +const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not folders. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the +/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside +/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is +/// left out. An empty value (the default folder) and a scratch +/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are. +fn shown_prefs(prefs: &Prefs) -> Value { + let mut all = prefs.to_json(); + if crate::file_access::current().is_none() { + return all; + } + // The app trims a folder before it uses it. + let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err(); + for key in PATH_PREFS { + let Some(path) = all[key].as_str() else { continue }; + if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) { + all[key] = Value::Null; + } + } + if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) { + for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) { + if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) { + settings.remove("folder"); + } + } + } + all +} + fn get(s: &mut Session, p: &Value) -> Result<Value> { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); match str_param(p, "key") { Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()), Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))), @@ -911,7 +949,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> { if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect())) } @@ -925,11 +963,11 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> { let mut next = s.prefs.clone(); next.reset(cat); s.apply_prefs(next); - Ok(json!({ "prefs": s.prefs.to_json() })) + Ok(json!({ "prefs": shown_prefs(&s.prefs) })) } fn list(s: &mut Session, _: &Value) -> Result<Value> { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Array( PREF_SPECS .iter() diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index b75066ec6a..fce76bcc27 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -17,7 +17,11 @@ //! 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. The copies it had in the old area stay there until it has written them again +//! in the new one, and an entry `<new area>/keeps-<old area>` ([`mark_left`]) tells other apps +//! that those copies belong to a running app, so none of them offers or discards them meanwhile. //! //! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so //! when unsure an area counts as held, and its app repairs what another app did meanwhile: each @@ -59,6 +63,9 @@ const BLOB_EXT: &str = ".blob"; /// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix /// seconds, as text). const BEAT: &str = "heartbeat"; +/// The start of an entry `<area>/keeps-<left area>`: the app holding `<area>` still keeps the copies +/// it left in `<left area>` ([`mark_left`]). +const KEEPS: &str = "keeps-"; /// The least time without a heartbeat after which an area's app counts as gone (seconds); else /// three Data Recovery intervals. const MIN_STALE: i64 = 180; @@ -100,6 +107,12 @@ pub trait RecoveryStore: Send + Sync { fn lock(&self, _area: &str) -> std::result::Result<Lock, String> { Ok(Lock::Unsupported) } + /// Does `hold`, which [`RecoveryStore::lock`] gave for area `area`, still hold it? A lock on a + /// file or folder that was removed or replaced since holds nothing; when that can't be told now, + /// it does (default: it does). + fn still_held(&self, _area: &str, _hold: &(dyn std::any::Any + Send + Sync)) -> bool { + true + } /// Stores without locks: also hold area `area` in a way other apps can see that lasts exactly as /// long as this app runs, if the store has one (the browser's Web Locks) → it, until dropped. fn announce(&self, _area: &str) -> Option<Hold> { @@ -266,7 +279,60 @@ impl FolderStore { builder.recursive(true); #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); - builder.create(dir).map_err(|e| format!("{}: {e}", dir.display())) + builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true)) + } + + /// The first folder on the way to `dir` (`dir` included), from the top down, that is not a + /// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of + /// them can't be checked. + fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> { + let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect(); + way.reverse(); + for p in way { + match std::fs::metadata(p) { + Ok(m) if m.is_dir() => {} + Ok(_) => return Some((p, true)), + Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)), + Err(_) => return None, + } + } + None + } + + /// The message for error `e` on folder `dir`, which was being made (`making`) or read. It + /// names the first folder on the way to `dir` that is not a folder, or is missing (when + /// `making`, the one that couldn't be made), and otherwise `dir`. + fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String { + match Self::in_the_way(dir) { + Some((p, true)) => format!("{} is not a folder: {e}", p.display()), + Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()), + Some((p, false)) if p != dir => format!("{} is missing", p.display()), + _ => format!("{}: {e}", dir.display()), + } + } + + /// The message for error `e` on file `f`. It names the first folder on the way to `f` that is + /// not a folder or is missing, and otherwise `f`. + fn file_error(f: &std::path::Path, e: &std::io::Error) -> String { + match f.parent().and_then(Self::in_the_way) { + Some((p, true)) => format!("{} is not a folder: {e}", p.display()), + Some((p, false)) => format!("{} is missing", p.display()), + None => format!("{}: {e}", f.display()), + } + } + + /// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a + /// file is on the way to it: Windows reports that as a missing file, and it is named as for a + /// listing. + pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> { + match r { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) { + Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())), + _ => Ok(()), + }, + Err(e) => Err(Self::file_error(f, &e)), + Ok(()) => Ok(()), + } } /// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix) @@ -274,7 +340,7 @@ impl FolderStore { /// linked to a folder of its own in a shared recovery folder looked free, and claiming it /// wrote the copies through the link. fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> { - let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?; if !m.file_type().is_dir() { return Err(format!("{}: not a folder of its own", dir.display())); } @@ -286,11 +352,17 @@ impl FolderStore { } /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist. + /// A file on the way to `dir` is an error: Windows reports such a folder as missing. fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> { let entries = match std::fs::read_dir(dir) { Ok(e) => e, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), - Err(e) => return Err(format!("{}: {e}", dir.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return match Self::in_the_way(dir) { + Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())), + _ => Ok(vec![]), + }; + } + Err(e) => return Err(Self::folder_error(dir, &e, false)), }; Ok(entries .filter_map(|e| e.ok()) @@ -325,7 +397,7 @@ impl FolderStore { match e.kind() { std::io::ErrorKind::NotFound => Ok(Lock::Busy), std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy), - _ => Err(format!("{}: {e}", path.display())), + _ => Err(Self::file_error(path, &e)), } } @@ -337,7 +409,7 @@ impl FolderStore { Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), // The lock file was removed meanwhile, and locking it failed instead. Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy), - Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)), } } @@ -385,10 +457,10 @@ impl RecoveryStore for FolderStore { if let Some(dir) = f.parent() { Self::own_area(dir)?; } - if !std::fs::symlink_metadata(&f).map_err(|e| format!("{}: {e}", f.display()))?.file_type().is_file() { + if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() { return Err(format!("{}: not a plain file", f.display())); } - std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display())) + std::fs::read(&f).map_err(|e| Self::file_error(&f, &e)) } fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { let f = self.file(name)?; @@ -396,14 +468,11 @@ impl RecoveryStore for FolderStore { Self::create_area(dir)?; Self::own_area(dir)?; } - vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display())) + vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e)) } fn remove(&self, name: &str) -> std::result::Result<(), String> { let f = self.file(name)?; - match std::fs::remove_file(&f) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())), - _ => Ok(()), - } + Self::removed(&f, std::fs::remove_file(&f)) } fn location(&self) -> String { self.0.to_string_lossy().into_owned() @@ -416,6 +485,16 @@ impl RecoveryStore for FolderStore { Self::create_area(&dir)?; Self::lock_area(&dir) } + fn still_held(&self, area: &str, hold: &(dyn std::any::Any + Send + Sync)) -> bool { + let (Some(file), Ok(dir)) = (hold.downcast_ref::<std::fs::File>(), self.area(area)) else { return true }; + let path = dir.join(LOCK_FILE); + // Only a lock file or folder that is gone, or another one in its place, ends the hold. One + // that can't be read now (a network folder that doesn't answer) still counts as held. + match std::fs::symlink_metadata(&path) { + Ok(_) => Self::own_area(&dir).is_ok() && Self::still_named(file, &path), + Err(e) => e.kind() != std::io::ErrorKind::NotFound, + } + } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty // (copies came back meanwhile) stays. The caller still holds the lock here. @@ -457,6 +536,12 @@ pub struct Recovery { /// heartbeat it wrote there: that one doesn't make them look running, a newer one does (their /// app was only paused and came back). adopted: BTreeMap<String, i64>, + /// Copies in an area this session had to leave (`keep_hold`), by document uid, with their + /// store: each stays until its document has a copy in the new area, or needs none. They are + /// still this session's: never offered or discarded as a crash's here. + left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>, + /// The `<own area>/keeps-<left area>` entries this session wrote in its area ([`mark_left`]). + marked: BTreeSet<String>, } impl Recovery { @@ -526,14 +611,120 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>) /// Refresh this session's hold on its area (the app does this every [`HEARTBEAT_EVERY`] seconds, /// so also on its first frame after its timers were paused): its heartbeat in a store without -/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now). -/// Best effort: a missed beat only lets another app offer the copies once three intervals have -/// passed. +/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now), +/// and those whose only copy is in an area the session left (`keep_hold`). Best effort: a missed +/// beat only lets another app offer the copies once three intervals have passed. pub fn heartbeat(s: &mut Session) -> Vec<u64> { + let mut lost = keep_hold(s); + // An entry that couldn't be written before, or that was removed since (by the user, a + // cleaner), is written now. + if let Some(own) = &s.recovery.own + && !s.recovery.marked.is_empty() + && let Ok(names) = own.store.list() + { + s.recovery.marked.retain(|n| names.contains(n)); + } + mark_left(s); if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) { let _ = beat(own.store.as_ref(), &own.area); } - repair(s) + // A copy that was being written when the session left its area finished there. A document + // that gets no copy now (a complex one) keeps the copy it has. + lost.extend( + s.recovery + .left + .keys() + .filter(|&&uid| s.documents().iter().any(|d| d.uid == uid && d.recovery.is_none() && skip_reason(s, d).is_none())) + .copied(), + ); + lost.extend(repair(s)); + lost.sort_unstable(); + lost.dedup(); + lost +} + +/// Check that this session's lock still holds its area (stores with locks). The area's lock file, +/// or the whole area, can be removed while the app runs (by the user, a cleaner, another program), +/// and then another app can lock the area and offer or delete this session's copies as a crash's. +/// The area is locked again, made again first if it went (its copies are then written again). +/// When another app holds it now, the session takes a new area, and each copy left in the old one +/// stays until its document has a copy in the new one. → the documents whose copies must be +/// written again. +fn keep_hold(s: &mut Session) -> Vec<u64> { + drop_left_done(s); + let Some(own) = &mut s.recovery.own else { return vec![] }; + if own.beats || own.hold.as_deref().is_none_or(|h| own.store.still_held(&own.area, h)) { + return vec![]; + } + let (store, area) = (own.store.clone(), own.area.clone()); + // The lock held is on a file the area no longer has, so it holds nothing: it goes first (a + // removed file this app still has open can keep a new lock file from being made). + own.hold = None; + if let Ok(Lock::Held(h)) = store.lock(&area) { + own.hold = Some(h); + return repair(s); + } + let mut lost = vec![]; + for st in &mut s.docs { + if let Some(c) = st.recovery.take() { + s.recovery.left.insert(st.uid, (store.clone(), c.file)); + lost.push(st.uid); + } + } + s.recovery.own = None; + // Best effort: when no area can be taken now, the next recovery save says why. + let _ = claim(s, &store); + lost +} + +/// The copies left in an area this session left whose documents were saved, reverted or closed +/// since go. +fn drop_left_done(s: &mut Session) { + let done: Vec<u64> = s.recovery.left.keys().filter(|&&uid| !s.documents().iter().any(|d| d.uid == uid && d.is_dirty())).copied().collect(); + for uid in done { + drop_left(s, uid); + } +} + +/// Document `uid`'s copy in an area this session left goes. 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); + mark_left(s); + } +} + +/// Record in this session's area each area it left that still holds copies of its documents: an +/// entry `<own area>/keeps-<left area>`, so another app leaves those copies alone while this one +/// runs ([`keeper`]). The entries of areas that hold none of its copies any more go. Best effort: +/// an entry not written or removed now is written or removed at the next heartbeat. +fn mark_left(s: &mut Session) { + let Some(own) = &s.recovery.own else { return }; + let (store, area) = (own.store.clone(), own.area.clone()); + let want: BTreeSet<String> = s + .recovery + .left + .values() + .filter(|(st, _)| st.location() == store.location()) + .filter_map(|(_, f)| f.split_once('/').map(|(a, _)| a)) + .filter(|a| *a != area) + .map(|a| format!("{area}/{KEEPS}{a}")) + .collect(); + let gone: Vec<String> = s.recovery.marked.difference(&want).cloned().collect(); + for n in gone { + if store.remove(&n).is_ok() { + s.recovery.marked.remove(&n); + } + } + for n in want { + if !s.recovery.marked.contains(&n) && store.write(&n, b"").is_ok() { + s.recovery.marked.insert(n); + } + } } /// Forget this session's copies that are missing from its area, so the next recovery save writes @@ -555,10 +746,13 @@ pub fn repair(s: &mut Session) -> Vec<u64> { /// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the /// session's lifetime (locked, or with a heartbeat and an announcement where the store has them). pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> { - if let Some(own) = &s.recovery.own { - if own.store.location() == store.location() { + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + // Documents whose copies went are left without one, so the caller writes them again. + keep_hold(s); + if let Some(own) = &s.recovery.own { return Ok(own.area.clone()); } + } else if s.recovery.own.is_some() { // The store moved (the preference names another folder): copies start again there. release(s); } @@ -578,6 +772,9 @@ pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> } }; s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold, beats }); + // The entries written before are in the area this session had (released or left). + s.recovery.marked.clear(); + mark_left(s); return Ok(area); } Err(EngineError::Other("no free recovery area".into())) @@ -588,7 +785,14 @@ pub fn release(s: &mut Session) { for st in &mut s.docs { st.recovery = None; } + // Copies left in an area this session had to leave go too. + let left: Vec<u64> = s.recovery.left.keys().copied().collect(); + for uid in left { + drop_left(s, uid); + } let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return }; + // Its `keeps-` entries go with the area's other entries. + s.recovery.marked.clear(); let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { let _ = store.remove(name); @@ -750,12 +954,16 @@ pub struct RecoveryJob { pub title: String, meta: Meta, save: SaveJob, + /// The store the copy goes to (the preference can name another folder before it is finished). + store: Arc<dyn RecoveryStore>, } impl RecoveryJob { /// Encode the snapshot and store it with its details and the images the area doesn't have - /// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`. - pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result<Value, String> { + /// yet, in the store the job was made for → `{file, title, bytes (the document's), imageBytes + /// (images written now)}`. + pub fn write(&self) -> std::result::Result<Value, String> { + let store = self.store.as_ref(); let doc = self.save.doc(); // A linked image keeps its preview, as a save writes it. let linked = doc.linked_only_images(); @@ -808,13 +1016,36 @@ 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}")); + } + mark_left(s); + } + // 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 +1098,11 @@ fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String { /// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified /// documents that get none). pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<RecoveryJob>, Vec<Value>)> { + // A lost lock is taken again first: copies that go with it are written again, even of + // documents unchanged since. + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + keep_hold(s); + } // Copies another app removed are written again. repair(s); let skipped: Vec<Value> = s @@ -923,7 +1159,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco // Filled in as the copy is written. images: BTreeMap::new(), }; - out.push(RecoveryJob { uid: st.uid, file, title, meta, save }); + out.push(RecoveryJob { uid: st.uid, file, title, meta, save, store: store.clone() }); } Ok((out, skipped)) } @@ -954,7 +1190,7 @@ pub(crate) fn save_copies(s: &mut Session) -> Result<()> { return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into())); } for j in jobs { - j.write(store.as_ref()).map_err(EngineError::Other)?; + j.write().map_err(EngineError::Other)?; j.finish(s); } Ok(()) @@ -966,6 +1202,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 +1213,16 @@ pub fn forget_all(s: &mut Session) { release(s); } -/// The copies of documents with no unsaved changes left (undone back to the saved state) go. +/// The copies of documents with no unsaved changes left (undone back to the saved state) go, and +/// the copies left in an area this session left of documents closed since. pub fn forget_clean(s: &mut Session) { let uids: Vec<u64> = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect(); for uid in uids { forget(s, uid); } + if !s.script_in_run { + drop_left_done(s); + } } fn save(s: &mut Session, _: &Value) -> Result<Value> { @@ -988,7 +1230,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> { let (jobs, skipped) = jobs(s, &store)?; let mut saved = vec![]; for j in jobs { - saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?); + saved.push(j.write().map_err(EngineError::Other)?); j.finish(s); } forget_clean(s); @@ -1018,6 +1260,10 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } match store.lock(area) { Ok(Lock::Held(h)) => { + // Copies an app left here when it had to leave the area stay its own while it runs. + if let Some(who) = keeper(s, store, area) { + return who; + } hold.push(h); Owner::Gone } @@ -1026,6 +1272,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } + if let Some(who) = keeper(s, store, area) { + return who; + } // Best effort: without it another app might offer the copies too, which loses nothing. if take && let Ok(t) = beat(store, area) { s.recovery.adopted.insert(area.to_string(), t); @@ -1035,6 +1284,42 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } } +/// Who keeps the copies left in `area` by an app that had to leave it, from the entries +/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`: +/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is +/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be +/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> { + let Ok(names) = store.list() else { return Some(Owner::Running) }; + let mark = format!("/{KEEPS}{area}"); + let mut who = None; + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + match holder(s, store, k) { + Owner::Mine => return Some(Owner::Mine), + Owner::Running => who = Some(Owner::Running), + Owner::Gone => {} + } + } + who +} + +/// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a +/// lock taken to tell goes at once). An area whose lock fails (not a recovery area of this +/// account, a lock file that can't be opened) is nobody's. +fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner { + if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { + return Owner::Mine; + } + match store.lock(area) { + Ok(Lock::Held(_)) | Err(_) => Owner::Gone, + Ok(Lock::Busy) => Owner::Running, + Ok(Lock::Unsupported) => { + let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()); + if runs { Owner::Running } else { Owner::Gone } + } + } +} + /// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The /// area is removed while it is still held, so an app taking a new area of that name meanwhile /// finds it held or gone and takes the next name. @@ -1057,9 +1342,15 @@ fn open_copies(s: &Session) -> Vec<String> { s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect() } +/// The copies of documents open now in this session that are in an area it left, in `store`. +fn left_copies(s: &Session, store: &dyn RecoveryStore) -> Vec<String> { + s.recovery.left.values().filter(|(st, _)| st.location() == store.location()).map(|(_, f)| f.clone()).collect() +} + fn list(s: &mut Session, _: &Value) -> Result<Value> { let store = need_store(s, "file.recovery.list")?; let open = open_copies(s); + let left = left_copies(s, store.as_ref()); let copies = copies_in(&store.list().map_err(EngineError::Other)?); let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { @@ -1071,7 +1362,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> { let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, - "open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running, + "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } // A gone app's empty area is tidied away. @@ -1095,7 +1386,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/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8567a7ec21..8c86f0dfe3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 29a357f730..746be9c244 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin use vectorcraft_geom::{Rect, shapes}; use super::{ - CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or, - targets, + CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, ids_param, index, number_or, object_param, + switch_or, targets, }; /// The most slices one Divide Slices makes. @@ -680,7 +680,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> { fn set_rect(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.slice.setRect"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let r = rect_param(p, C)?; let d = &s.doc()?.doc; if d.slice(id).is_none() { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index e97659bfc9..4153fedd51 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -206,11 +206,31 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { ids.is_empty() && p.get("ids").is_none() && p.get("id").is_none() } +/// The objects `stroke.set` edits with no `item`: a selected group or layer with a stroke row of +/// its own edits that row, as the Stroke panel does with the group targeted (#6739); the other +/// targets reach their painted leaves, as before. +fn own_stroke_or_leaves(s: &Session, p: &Value) -> Result<Vec<vectorcraft_doc::NodeId>> { + let picked = super::targets(s, p)?; + let d = &s.doc()?.doc; + let own = |id: &vectorcraft_doc::NodeId| { + d.node(*id).is_some_and(|n| { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.is_compound_shape() && n.appearance.stroke().is_some() + }) + }; + let (mut out, rest): (Vec<_>, Vec<_>) = picked.into_iter().partition(own); + for id in super::leaf_targets(s, &rest)? { + if !out.contains(&id) { + out.push(id); + } + } + Ok(out) +} + fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; let item = item_target(s, p, C)?.of_kind(s, false); - let mut ids = item.targets(s, p)?; + let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? }; let new_art = no_targets(&ids, p); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. @@ -261,7 +281,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { return Ok(()); } if index.is_none() && n.appearance.stroke().is_none() { - n.appearance.set_stroke(Paint::solid(Color::BLACK)); + // Options alone (cap, join, miter limit, dashes, alignment) don't make a stroke + // visible: the row made for them has no paint. A weight, an arrowhead or a profile + // still gives the object the black stroke the panel draws (#6739). + let visible = change.weight.is_some() || sa.is_some() || ea.is_some() || profile.is_some(); + n.appearance.set_stroke(if visible { Paint::solid(Color::BLACK) } else { Paint::None }); } if let Some(st) = n.appearance.stroke_at_mut(index) { set(st); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index a5f677f5dd..606ab40fcf 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -19,7 +19,7 @@ use vectorcraft_doc::symbol_overrides::SymbolOverride; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration}; use vectorcraft_geom::{Affine, Point}; -use super::brushsym::{natural_size, place_xf}; +use super::brushsym::{fit_parts, natural_size, place_xf}; use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param}; /// The name of the undo step that enters symbol editing mode. @@ -425,8 +425,8 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec<Sy } /// Each instance's appearance changes to objects of symbol `name`'s art follow those objects -/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes to -/// objects the art still has. That covers the instances on the layers and in the art of the +/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes the art +/// still draws ([`fit_parts`]). That covers the instances on the layers and in the art of the /// document's other symbols, graph designs, pattern tiles (the tiles pattern editing's Cancel puts /// back included) and brushes, opacity-mask art included. The editing layer holds no instance of /// the symbol, since [`sync`] refuses one. @@ -435,7 +435,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new: let mut follow = |o: &mut Vec<SymbolOverride>| { follow_paths(o, old, new); match &art { - Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art), + Some(art) => fit_parts(o, art), None => o.clear(), } }; diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 331227e3be..c047b94900 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); supplied align, leader and alignOn must be strings, and alignOn cannot be empty", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -41,7 +41,7 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; - if !(0.0..=100_000.0).contains(&position) { + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { return Err(bad(C, "positions must be between 0 and 100000 pt")); } let align = match s.get("align") { @@ -60,16 +60,25 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { None | Some(Value::Null) => "", Some(Value::String(value)) => value.as_str(), Some(_) => return Err(bad(C, "`leader` must be a string")), + }; + let (label, max) = ("`leader`", TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(bad(C, format!("{label} must be at most {max} characters"))); } - .chars() - .take(8) - .collect(); let align_on = match s.get("alignOn") { None | Some(Value::Null) => '.', - Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?, + Some(Value::String(value)) => { + let mut chars = value.chars(); + let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?; + if chars.next().is_some() { + let label = "`alignOn`"; + return Err(bad(C, format!("{label} must be one character"))); + } + c + } Some(_) => return Err(bad(C, "`alignOn` must be a string")), }; - stops.push(TabStop { position, align, leader, align_on }); + stops.push(TabStop { position, align, leader: leader.to_string(), align_on }); } stops.sort_by(|a, b| a.position.total_cmp(&b.position)); stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); @@ -77,10 +86,39 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { } fn set(s: &mut Session, p: &Value) -> Result<Value> { + // More than the limit is taken only when every targeted paragraph already holds at least as + // many stops, so a paragraph that holds more can still lose them one at a time. The supplied + // list is counted before it is read. + let max = TabStop::MAX_STOPS; + let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len); + if supplied > max && supplied > fewest_held(s, p)? { + return Err(bad("text.tabs.set", format!("at most {max} tab stops"))); + } let stops = parse_stops(p)?; apply(s, p, stops, "Tab Stops") } +/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none). +fn fewest_held(s: &Session, p: &Value) -> Result<usize> { + let ids = text_targets(s, p, "text.tabs.set")?; + let range = TextRange::parse(p, "text.tabs.set")?; + let doc = &s.doc()?.doc; + let mut fewest: Option<usize> = None; + for id in &ids { + if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) { + let held = match para_span(range, t) { + None => t.para_styles().map(|pa| pa.tabs.len()).min(), + Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(), + }; + fewest = match (fewest, held) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + } + } + Ok(fewest.unwrap_or(0)) +} + fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> { let ids = text_targets(s, p, "text.tabs.set")?; let range = TextRange::parse(p, "text.tabs.set")?; @@ -128,7 +166,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> { #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -139,6 +177,59 @@ mod tests { } } + /// A leader of more than eight characters, an `alignOn` of more than one character and a list + /// of more than 256 stops are errors, not cut short. + #[test] + fn long_leaders_long_align_on_and_too_many_stops_are_errors() { + let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); + assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); + assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap(); + let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect(); + let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string(); + assert!(e.contains("at most 256 tab stops"), "{e}"); + // The supplied list is counted, not the stops left once those at one position merge. + let same: Vec<Value> = (0..257).map(|_| json!({"position": 5})).collect(); + assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256)); + let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); + assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); + } + + /// A paragraph that already holds more than 256 stops (from a file) can lose them one at a + /// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and + /// a paragraph with fewer stops targeted beside it keeps the limit. + #[test] + fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() { + use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap()); + let held: Vec<TabStop> = + (0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect(); + s.edit("Load", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.edit_paras(None, |pa| pa.tabs = held.clone()); + } + Ok(()) + }) + .unwrap(); + let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::<Vec<_>>() }); + assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299)); + assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299); + // The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused, + // and 298 for the first alone is taken. + s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap(); + let mut both = list(298); + assert!(s.execute("text.tabs.set", &both).is_err()); + both["start"] = json!(0); + both["end"] = json!(0); + assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298)); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 00f1dd8adb..edfab850f5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -11,8 +11,8 @@ use super::menucmds::whole_or; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index, + number_or, object_param, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec<CommandSpec> { @@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reset Tracking and Kerning", [], Some("Cmd+Alt+Q"), - "{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}", + "{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}", has_selection, reset_tracking ), @@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { fn edit_range(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.editRange"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len, C)?; @@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId fn start_list(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.startList"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let at = offset_or(p, "at", len, C)?.min(len); @@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> { } fn get_range(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; + let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?; if switch_or(p, "story", false, "text.getRange")? { return get_story(s, p, id); } @@ -430,6 +430,8 @@ pub(crate) struct CharChange { leading: Option<Option<f64>>, tracking: Option<f64>, kerning: Option<Option<f64>>, + /// The space before a character that starts a line by itself (`kerningBefore`). + kerning_before: Option<f64>, optical_kerning: Option<bool>, baseline_shift: Option<f64>, h_scale: Option<f64>, @@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> { impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> { - check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; + check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; check_switches( p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"], @@ -720,6 +722,7 @@ impl CharChange { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None), Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)), }, + kerning_before: num("kerningBefore"), optical_kerning: match p.get("kerning") { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true), Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false), @@ -777,6 +780,7 @@ impl CharChange { && self.leading.is_none() && self.tracking.is_none() && self.kerning.is_none() + && self.kerning_before.is_none() && self.optical_kerning.is_none() && self.baseline_shift.is_none() && self.h_scale.is_none() @@ -883,6 +887,9 @@ impl CharChange { if let Some(v) = self.kerning { st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); } + if let Some(v) = self.kerning_before { + st.kerning_before = v.clamp(-1000.0, 10000.0); + } if let Some(v) = self.optical_kerning { st.optical_kerning = v; } @@ -1254,7 +1261,7 @@ fn drawn_glyph( fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setGlyphAlternate"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let gid = match p.get("gid") { None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")), Some(Value::Null) => None, @@ -1344,7 +1351,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.glyphAlternates"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); @@ -1359,7 +1366,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> { fn touch_type(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.touchType"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; check_numbers(p, &["start"], C)?; let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?; if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -1585,7 +1592,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> { fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.createInPath"; - let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; + let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; let on_path = match str_param(p, "mode").unwrap_or("area") { "area" => false, "onPath" | "path" => true, @@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> { s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default() } +/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it +/// came from a caret ([`type_ranges`]). +pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>); + /// The text ranges a type shortcut changes: the range of `id`, else all the text of the type /// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the /// story after Select All in threaded text, [`editing_story`]), else all the text of every selected /// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte -/// (None: nothing changes there). Each range comes with whether it came from a caret. -pub(crate) fn type_ranges( - s: &mut Session, - p: &Value, - c: &str, - at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, -) -> Result<Vec<(NodeId, usize, usize, bool)>> { +/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret. +pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> { // (text, its range; None: all of it). let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? { // An object that isn't type is refused as `ids` of it is. @@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges( let text = text_ref(s, id)?.plain_text(); let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len()))); let (a, b, caret) = if a < b { - (a, b, false) + (a, b, None) } else { match at_caret(&text, a) { - Some((a, b)) => (a, b, true), + Some((x, y)) => (x, y, Some(a)), None => continue, } }; @@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { Step::Tracking | Step::Kerning => prefs.tracking_increment, Step::BaselineShift => prefs.baseline_shift_increment, }; - // The ranges stepped. At a caret: kerning is the space after the character before it, leading - // the paragraph's; the rest wait for a selection. + // The ranges stepped. At a caret: kerning is the space after the character before it, or at + // the start of a line the space before the character after it (#5111), leading the + // paragraph's; the rest wait for a selection. let ranges = type_ranges(s, p, C, |text, a| match step { - Step::Kerning => Some((edit::prev_char(text, a), a)), + Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)), Step::Leading => { let para = edit::paragraph_at(text, a); Some((para.start, para.end)) } _ => None, })?; + // At a caret, the kerning stepped: (whether it is the space before the character, its value). let mut caret_kerning = Vec::with_capacity(ranges.len()); for &(id, a, b, caret) in &ranges { - let value = if matches!(step, Step::Kerning) && caret { - let t = text_ref(s, id)?; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)) - } else { - None + let value = match caret { + Some(c) if matches!(step, Step::Kerning) => { + let t = text_ref(s, id)?; + if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) { + Some((true, edit::style_at(&t.runs, a + 1).kerning_before)) + } else { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))) + } + } + _ => None, }; caret_kerning.push(value); } @@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?); } Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), - Step::Kerning => { - let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0)); - st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); - } + Step::Kerning => match caret_kerning.get(range_index).copied().flatten() { + Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0), + caret => { + let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v); + st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); + } + }, Step::BaselineShift => { st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?; } @@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { /// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. -/// The automatic method remains selected. +/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns +/// there: of the character before it, or at the start of a line the space before the character +/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected. fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> { - let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; + let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?; if !ranges.is_empty() { s.edit("Reset Tracking and Kerning", |d, _| { for &(id, a, b, caret) in &ranges { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - edit::style_range(&mut t.runs, a, b, |st| { - st.kerning = None; - if !caret { + let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before); + edit::style_range(&mut t.runs, a, b, |st| match before { + Some(true) => st.kerning_before = 0.0, + Some(false) => st.kerning = None, + None => { + st.kerning = None; + st.kerning_before = 0.0; st.tracking = 0.0; } }); @@ -1975,7 +1996,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> { fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.discardEmpty"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; if edit::runs_len(&text_ref(s, id)?.runs) > 0 { return Ok(json!({"removed": false})); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index d30e69df68..40d8af22ad 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef}; use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; pub fn specs() -> Vec<CommandSpec> { let mut v = style_cmds!( @@ -148,13 +148,13 @@ pub fn specs() -> Vec<CommandSpec> { "paraStyle", "Paragraph Styles", "Paragraph", - "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt" + "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position" )); v.push(spec( "paraStyle.edit", "Edit Paragraph Style", &[], - "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt", + "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position", |s, p| edit_style(s, p, Kind::Para), true, )); @@ -188,8 +188,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> { /// from a style instead leaves the text's own value alone. pub fn char_attrs(v: &CharStyle) -> Map<String, Value> { let mut m = to_attrs(v); - // A chosen glyph belongs to one character, not to a reusable character style. + // A chosen glyph belongs to one character, and the space before a line's first character to + // where it stands (#5111), not to a reusable character style. m.remove("glyphAlternate"); + m.remove("kerningBefore"); for (k, x) in [ ("font_version", json!(v.font_version)), ("fontAxes", json!(v.font_axes)), @@ -243,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> { ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), + ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), ] { m.insert(k.into(), x); } @@ -530,6 +533,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma if m.contains_key("glyphAlternate") { return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)")); } + if m.contains_key("kerningBefore") { + return Err(bad( + &c, + "`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)", + )); + } let mut st = with_attrs(&CharStyle::default(), m, &c)?; let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key); for (key, value, lo, hi) in @@ -569,8 +578,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma char_attrs(&st) } Kind::Para => { - let st = with_attrs(&ParaStyle::default(), m, &c)?; + let mut st = with_attrs(&ParaStyle::default(), m, &c)?; para_ranges(&st, m, kept, &c)?; + sort_tabs(&mut st, m, kept); para_attrs(&st) } }; @@ -587,7 +597,8 @@ const PARA_DISTANCE: f64 = 1296.0; /// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the /// ranges `text.setFormat` keeps them to: the indents and the space before and after, each /// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as -/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked. +/// `attrs` gives them), and the number of tab stops with the position and leader of each stop +/// `kept` does not hold. A value `kept` has is not checked. fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>, c: &str) -> Result<()> { let within = |key: &str, value: f64, lo: f64, hi: f64| { if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) } @@ -644,18 +655,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<Str } } if changed("tabs") { - let kept_tabs: Vec<f64> = kept - .and_then(|k| k.get("tabs")) - .and_then(Value::as_array) - .map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect()) - .unwrap_or_default(); - for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) { - within("tabs.position", stop.position, 0.0, 100_000.0)?; + let kept_tabs = kept_tab_stops(kept); + let max = TabStop::MAX_STOPS; + if st.tabs.len() > max.max(kept_tabs.len()) { + return Err(bad(c, format!("at most {max} tab stops"))); + } + for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) { + within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?; + let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER); + if stop.leader.chars().count() > max { + return Err(bad(c, format!("{label} must be at most {max} characters"))); + } } } Ok(()) } +/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out). +fn kept_tab_stops(kept: Option<&Map<String, Value>>) -> Vec<TabStop> { + let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array); + stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect() +} + +/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other, +/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`. +fn sort_tabs(st: &mut ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>) { + if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") { + st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + } +} + /// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a /// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes. fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> { @@ -1011,10 +1041,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name -/// is empty, which are skipped. → (the styles, the names in `src` of those skipped) +/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose +/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range +/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in +/// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) { - const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; + const RANGED: [&str; 16] = [ + "size", + "h_scale", + "v_scale", + "leading", + "baseline_shift", + "tcyUpDown", + "tcyLeftRight", + "stroke_width", + "left_indent", + "right_indent", + "first_line_indent", + "space_before", + "space_after", + "hyphenation", + "justification", + "tabs", + ]; let (mut defs, mut skipped) = (vec![], vec![]); for def in kind.defs(src) { let name = def.name.as_str(); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 87bca29048..77ecd9d786 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { /// The same for character styles: Auto kerning and the Roman baseline alignment, which the /// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to -/// one character, so it is not a reusable style attribute. +/// one character, and the space before a line's first character to where it stands (#5111), so +/// neither is a reusable style attribute. #[test] fn char_style_redefined_to_the_defaults_updates_its_text() { use vectorcraft_doc::CharAlign; @@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() { assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); let mut reusable = settable::<CharStyle>(); reusable.remove("glyphAlternate"); + reusable.remove("kerningBefore"); assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute"); s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute"); @@ -1729,3 +1731,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() { assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}"); } } + +/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept +/// as it is, and a stop at a held position with another alignment is a changed stop. A changed +/// stop's leader has at most eight characters and its Align On one character, a changed list has +/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position, +/// keeping the first of stops at one position. +#[test] +fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() { + let (mut s, _) = session_with_text("Text"); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap(); + let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held: Vec<Value> = + (0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect(); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone())); + let before = s.doc().unwrap().doc.clone(); + let mut moved = held.clone(); + moved[0]["align"] = json!("right"); + for (stops, why) in [ + (Value::from(moved), "`tabs.position`"), + (json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"), + (Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()), "at most 256 tab stops"), + (json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"), + ] { + for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] { + let params = match cmd { + "paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}), + _ => json!({"name": "Body", "attrs": {"tabs": stops}}), + }; + let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(error.contains(why), "{cmd}: {error}"); + } + } + let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()); + let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string(); + assert!(error.contains("at most 256 tab stops"), "{error}"); + assert_eq!(s.doc().unwrap().doc, before); + // The held stops stay while one is dropped and another attribute changes. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap(); + assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299)); + let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + assert_eq!( + def(&s)["tabs"], + json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."}, + {"position": 72.0, "align": "right", "alignOn": "."}]) + ); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap(); + assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set"); +} + +/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing, +/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes, +/// and load the others with their stops. +#[test] +fn loading_skips_paragraph_styles_out_of_range() { + let file = [ + ("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})), + ("Indent", json!({"left_indent": 2000.0})), + ("Space", json!({"space_after": -1300.0})), + ("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})), + ("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})), + ("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})), + ]; + let data = styles_file(&[], &file); + for cmd in ["paraStyle.load", "textStyle.loadAll"] { + let (mut s, _) = session_with_text("Title"); + let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r }; + assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}"); + let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap(); + assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}"); + } +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 33dad39d93..06368c6c7d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, + ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec<CommandSpec> { @@ -1273,7 +1273,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { }; let mut items = Vec::with_capacity(raw.len()); for it in raw { - let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(it.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1304,7 +1304,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.reshapePath"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1335,7 +1335,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setPathHandle"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 571c9c3469..5545e1be94 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { assert!(text(&s).para_at(1).roman_hanging); } +/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that; +/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a +/// value that is not true or false, a paragraph saved before the option opens with it on, and +/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it. +#[test] +fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none()); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); + assert!(back.korean_line_break_anywhere); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere); + // Saved before the option: on, the line breaks it was laid out with. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(old.korean_line_break_anywhere); + assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err()); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err()); + assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing"); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap(); + assert!(text(&s).para_at(1).korean_line_break_anywhere); + s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 2dda356146..ec458a0c9b 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); } +#[test] +fn convert_to_point_type_turns_block_off_keeping_its_sizes() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::<Vec<_>>(); + let fitted = sizes(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + assert!(!t.dynamic_text, "point type has Dynamic Text off"); + assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set"); + assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText"); + // Back to area type, the frame is not refitted: Block stays off. + s.execute("type.convertToAreaType", &json!({})).unwrap(); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + assert!(!t.dynamic_text, "Block does not come on again"); + assert_eq!(sizes(&t), fitted); + // Undo gives the Block frame back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_eq!(sizes(&t), fitted); +} + #[test] fn bad_params_and_other_type_are_refused() { let mut s = session(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index faaaf2145d..239ee748bf 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}", has_selection, to_point ), @@ -311,7 +311,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // The frame is worked out in line space, where the lines are; vertical type turns it + // into text space with the lines. + let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds); // Across, the frame spans the lines as they are aligned: from their starts to their ends // without the tracking after their last characters (`LineInfo::trailing`), not to the // ink, which tracking can pull past an advance. Area type aligns them alike. @@ -332,7 +335,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), _ => (0.0, slack), }; - let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); + let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0))); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); } @@ -368,6 +371,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { // Point type is in no thread: its first paragraph starts in it. t.first_para_continues = false; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + // The lines are in line space; the frame's bounds are taken there too. + let fb = lay.line_xf.inverse().transform_rect_bbox(fb); let plain = t.plain_text(); let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); // Soft wraps → explicit breaks (replace the space before the wrap when there is one). @@ -410,8 +415,14 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1, _ => x0, }; + // Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so + // converting back to area type does not turn it on again. + t.dynamic_text = false; t.kind = TextKind::Point; - t.xf *= Affine::translate((ox, baseline)); + // The origin is in the area type's line space; point type's line space puts its own + // origin there (vertical point type turns its lines about a different point). + let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY }; + t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse(); refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index c79ec0b86e..218871972f 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::select::selectable_ids; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param}; /// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in /// points, opacity in percent. @@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> { } fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?; + let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?; let st = s.doc()?; let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); let w = s.menu.wand.clone(); diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index 9e641a6e6a..d1c66d9d3c 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -645,7 +645,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator<Item = [u8; 3]> + '_ { /// The File Info text `s.metadata` lets into the files: `(keyword, text)`. fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> { - doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() + doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() } /// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata. diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..58832adf29 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -285,8 +285,8 @@ fn document_base(s: &Session) -> Value { json!({ "uid": st.uid, "title": st.title(), - // The File Info title (`file.info`), where `title` comes from the file name once the document has one. - "fileInfoTitle": d.title, + // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. + "fileInfoTitle": d.info_title(), "path": st.path, "dirty": st.is_dirty(), "revision": st.revision, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f1f188f00c..cd3a103a86 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,10 +171,6 @@ pub struct Session { /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder /// ([`Session::attach_scripts_dir`]). scripts_dir: Option<std::path::PathBuf>, - /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: - /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful - /// [`Session::store_grant`] for the id takes it out. - ignored_plugin_grants: std::sync::Mutex<std::collections::BTreeSet<String>>, /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option<cmd::plugin::HandedIn>, @@ -306,6 +302,8 @@ pub struct Session { pub(crate) press_point: Option<Option<(usize, cmd::gradient::StopOwner)>>, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>, + /// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]). + pub(crate) ruler_origin: Option<vectorcraft_tools::rulerguide::OriginDrag>, /// Which selected type overflows its frame, for the ports every tool but the Type tools shows. pub(crate) port_marks: vectorcraft_tools::text::PortMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index ce2e1d6783..4ec68dbbde 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -88,6 +88,13 @@ impl SourceMap { let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from("")); (seg.file, name, seg.line.saturating_add(line - seg.out_line), column) } + + /// The memory the map holds, in bytes: its lists and its file names, each name counted in full + /// even when another map shares it. + pub(crate) fn bytes(&self) -> usize { + let names: usize = self.files.iter().map(|f| f.len() + 2 * size_of::<usize>()).sum(); + self.files.capacity() * size_of::<Arc<str>>() + self.segments.capacity() * size_of::<Segment>() + names + } } /// How a preprocessor failure ends the run. diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index a8f0c18336..0821a11e71 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -221,12 +221,69 @@ fn current(link: &Link) -> std::result::Result<Rc<Host>, HostError> { } /// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), -/// the link its host functions use, and its heap after its last run. +/// the link its host functions use, its heap after its last run, and the source maps of the code +/// its earlier runs compiled. struct Engine { name: String, realm: Option<Realm>, link: Link, heap: usize, + maps: Rc<RefCell<KeptMaps>>, +} + +/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its +/// kept source maps. +type TakenEngine = (Option<Realm>, Link, Rc<RefCell<KeptMaps>>); + +/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its +/// earlier runs. +pub const KEPT_MAP_BYTES: usize = 8 << 20; + +/// A source map an engine kept from an earlier run: the realm's tag for the source +/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for +/// a script given as text. +struct KeptMap { + tag: u32, + map: include::SourceMap, + script: Option<Arc<str>>, + bytes: usize, +} + +/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most +/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept. +#[derive(Default)] +struct KeptMaps { + maps: std::collections::VecDeque<KeptMap>, + bytes: usize, +} + +impl KeptMaps { + fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option<Arc<str>>) { + let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len()); + let limit = kept_map_bytes(); + if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) { + return; + } + while self.bytes + bytes > limit { + let Some(old) = self.maps.pop_front() else { break }; + self.bytes -= old.bytes; + } + self.bytes += bytes; + self.maps.push_back(KeptMap { tag, map, script, bytes }); + } + + fn get(&self, tag: u32) -> Option<&KeptMap> { + self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i)) + } +} + +/// The bytes of source maps a named engine keeps. +fn kept_map_bytes() -> usize { + #[cfg(test)] + if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) { + return n; + } + KEPT_MAP_BYTES } /// The named engines of one thread's sessions, by their key, each in the order they were made. @@ -665,6 +722,14 @@ struct Host { /// The file, line and column of each error `$.evalFile` raised for a file it could not run, in /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>, + /// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and + /// [`vectorcraft_js::UNKNOWN_SOURCE`] before. + script_tag: Cell<u32>, + /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. + report_name: Arc<str>, + /// The source maps the named engine of the run kept from its earlier runs; `None` for a run + /// without one. + kept_maps: Option<Rc<RefCell<KeptMaps>>>, /// When the run started, where the platform has a clock. started: Option<std::time::Instant>, /// The time since [`Host::started`] at which a `$.sleep` ends. @@ -740,6 +805,23 @@ pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec<c } impl Host { + /// Gives the named engine of the run the source maps of the code the run compiled: its script + /// and each file `$.evalFile` gave. A run that never started its script compiled nothing. + fn keep_maps(&self) { + let (Some(kept), tag) = (&self.kept_maps, self.script_tag.get()) else { return }; + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return; + } + let mut kept = kept.borrow_mut(); + kept.keep(tag, std::mem::take(&mut *self.script_map.borrow_mut()), Some(self.report_name.clone())); + for (n, map) in std::mem::take(&mut *self.eval_maps.borrow_mut()).into_iter().enumerate() { + match u32::try_from(n + 1).ok().and_then(|n| tag.checked_add(n)).filter(|t| *t != vectorcraft_js::UNKNOWN_SOURCE) { + Some(t) => kept.keep(t, map, None), + None => break, + } + } + } + /// Adds a line to the run's console and, during a slice, to the session's. fn line(&self, entry: console::Entry, cut: bool) { let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; @@ -1358,34 +1440,55 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) { (RunError { kind, message, line: f.line, column: f.column }, file) } -/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark` -/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name -/// unless it is the script's own. -fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) { - let map = |m: &include::SourceMap, own: bool| { +/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in, +/// with that file's name unless it is the run's own script. +fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option<Arc<str>>) { + let map = |m: &include::SourceMap| { let (index, file, line, column) = m.locate(e.line, e.column); let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; - (RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file)) + (RunError { kind: e.kind.clone(), message, line, column }, index, file) }; // An error `$.evalFile` raised for a file it could not run, and no script caught, is at that // file's place. - if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { + if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; return (RunError { kind: e.kind.clone(), message, line, column }, Some(file)); } if e.line == 0 { return (e.clone(), None); } - // An error in code from another source, such as a function an earlier run of a named engine - // defined, has no place in this run's files, so it is given without one. + // An error in code from a source whose map is not at hand, such as code that `eval` compiled, + // is given without a place. let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None); - match tag { - 0 => map(&host.script_map.borrow(), true), + match realm.error_source() { + 0 => { + let (e, index, file) = map(&host.script_map.borrow()); + (e, (index > 0).then_some(file)) + } n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { - Some(m) => map(m, false), + Some(m) => { + let (e, _, file) = map(m); + (e, Some(file)) + } None => unplaced(), }, - _ => unplaced(), + // Code an earlier run of the named engine compiled, such as a function it defined, is at + // its place in that run's files: the run's script, a file it included, or a file + // `$.evalFile` gave. A script given as text is named by that run's name. + _ => { + let kept = host.kept_maps.as_ref().map(|k| k.borrow()); + match kept.as_ref().and_then(|k| k.get(realm.error_tag())) { + Some(kept) => { + let (e, index, file) = map(&kept.map); + let file = match &kept.script { + Some(name) if index == 0 && file.is_empty() => name.clone(), + _ => file, + }; + (e, Some(file)) + } + None => unplaced(), + } + } } } @@ -1438,17 +1541,17 @@ impl Session { } } - /// The realm of the named engine `name` for a run, with the link its host functions use: the - /// realm its last run left, or `None` for an engine made now. → the kind and text of the - /// failure when the engine cannot be had. - fn take_engine(&mut self, name: &str) -> std::result::Result<(Option<Realm>, Link), (include::FailKind, String)> { + /// The realm of the named engine `name` for a run, with the link its host functions use and the + /// source maps it kept: the realm its last run left, or `None` for an engine made now. → the + /// kind and text of the failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result<TakenEngine, (include::FailKind, String)> { if cfg!(target_arch = "wasm32") { return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); } with_engines(&self.script_engines, |engines| { if let Some(e) = engines.iter_mut().find(|e| e.name == name) { let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; - return Ok((Some(realm), e.link.clone())); + return Ok((Some(realm), e.link.clone(), e.maps.clone())); } if engines.len() >= MAX_ENGINES { return Err(( @@ -1457,15 +1560,20 @@ impl Session { )); } let link: Link = Rc::new(RefCell::new(None)); - engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); - Ok((None, link)) + let maps = Rc::new(RefCell::new(KeptMaps::default())); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() }); + Ok((None, link, maps)) }) } /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer - /// holds the run, and the engine keeps `realm`, or is dropped without one. + /// holds the run, and the engine keeps `realm`, with the source maps of the code the run + /// compiled, or is dropped without one. fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) { let host = link.borrow_mut().take(); + if let (Some(h), Some(_)) = (&host, &realm) { + h.keep_maps(); + } drop(host); let Some(name) = engine else { return }; let mut realm = realm; @@ -1668,9 +1776,9 @@ impl Session { }; let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; - let (engine, realm, link) = match kept { - None => (None, None, Rc::new(RefCell::new(None))), - Some((engine, (realm, link))) => (Some(engine), realm, link), + let (engine, realm, link, kept_maps) = match kept { + None => (None, None, Rc::new(RefCell::new(None)), None), + Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)), }; // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them // from its first run, and they act for the run that calls them. @@ -1716,6 +1824,9 @@ impl Session { script_map: RefCell::default(), eval_maps: RefCell::default(), eval_failures: RefCell::default(), + script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE), + report_name: Arc::from(name.as_str()), + kept_maps, started: clock(), wake_at: Cell::new(None), can_wait: realm.wait_flag(), @@ -1752,8 +1863,12 @@ impl Session { .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) - .and_then(|()| realm.start(&expanded.source)) - .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) + .and_then(|()| { + let r = realm.start(&expanded.source); + host.script_tag.set(realm.script_tag()); + r + }) + .map_err(|e| located(&host, &realm, &e)) } }; if let Err((e, file)) = started { @@ -1787,6 +1902,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 +1917,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 { @@ -1831,7 +1947,7 @@ impl Session { fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> { job.host.dialogs.stopped(); let fuel_left = job.realm.fuel_left(); - let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e); + let (e, file) = located(&job.host, &job.realm, e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); self.end_job(job, e.kind == RunErrorKind::Internal); @@ -2126,6 +2242,8 @@ thread_local! { pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) }; /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. pub(crate) static TEST_ENGINE_HEAP: Cell<Option<usize>> = const { Cell::new(None) }; + /// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`]. + pub(crate) static TEST_KEPT_MAP_BYTES: Cell<Option<usize>> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 83a0367751..887c740936 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, @@ -124,6 +123,7 @@ impl Session { press_type_selection: None, press_point: None, ruler_guide: None, + ruler_origin: None, port_marks: Default::default(), font_search: None, link_search: None, diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index abe9121eff..23079fe901 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -82,7 +82,13 @@ impl Session { && params.get("ids").is_none_or(Value::is_null) && params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$')); if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() { - cmd::given_objects(self, params, id)?; + // `effect.refreshDeferred` looks its objects up itself: they can also be in the art of + // a symbol, pattern, brush or opacity mask. + if id == "effect.refreshDeferred" { + cmd::given_object_ids(params, id)?; + } else { + cmd::given_objects(self, params, id)?; + } } if SELECTS_GIVEN_IDS.contains(&id) { self.check_given_ids(id, params)?; diff --git a/crates/engine/src/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<BTreeMap<PathBuf, Arc<PluginData>>> = Mutex::new(BTreeMap::new()); +/// The grants of plug-in stores that the sessions of the process don't count, and when each was +/// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is. +static IGNORED_PLUGIN_GRANTS: Mutex<IgnoredGrants> = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 }); +/// How many of the latest grant writes [`IgnoredGrants`] keeps. +const MAX_GRANT_WRITES_KEPT: usize = 256; + +/// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id. +struct IgnoredGrants { + /// The grants that should be gone but could not be removed: no session in the process counts + /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id + /// ([`Session::store_grant`]). + ignored: BTreeSet<(PathBuf, String)>, + /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the + /// count of writes it made ([`IgnoredGrants::writes`]). + written: BTreeMap<(PathBuf, String), u64>, + /// How many grant writes the sessions of the process have made ([`Session::grant_writes`]). + writes: u64, +} /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -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<std::path::Component> = Vec::new(); + for part in absolute.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!(parts.last(), Some(std::path::Component::Normal(_))) { + parts.pop(); + } + } + _ => parts.push(part), + } + } + for kept in (1..=parts.len()).rev() { + let base: PathBuf = parts[..kept].iter().collect(); + if let Ok(found) = std::fs::canonicalize(&base) { + return parts[kept..].iter().fold(found, |path, part| path.join(part)); + } + } + parts.iter().collect() +} + /// The regular files of `dir` with the extension `ext` (any case), by file name, at most /// [`MAX_STORE_FILES`]; none when the folder can't be read. fn list_files(dir: &Path, ext: &str) -> Vec<PathBuf> { @@ -191,6 +240,9 @@ fn lock_disabled(_: &Path) -> Option<std::fs::File> { thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; + /// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when + /// another program holds it open. + pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; /// 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<bool> = 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<String, vectorcraft_plugins::permissions::Grant> { - let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()); + let key = plugin_store_key(dir); let mut grants = stored_grants(dir); - grants.retain(|id, _| !ignored.contains(id)); + let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) { + grants.remove(id); + } grants } @@ -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<vectorcraft_plugins::permissions::Grant>) -> bool { let done = self.write_grant(id, grant); - if done { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id); + if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + shared.ignored.remove(&entry); + shared.writes = shared.writes.saturating_add(1); + let writes = shared.writes; + shared.written.insert(entry, writes); + if shared.written.len() > MAX_GRANT_WRITES_KEPT + && let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone()) + { + shared.written.remove(&oldest); + } } done } - /// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next - /// succeeds for it or another store is attached: for a grant that should be gone and could not - /// be removed, which would otherwise approve the bytes it covers. - pub(crate) fn ignore_stored_grant(&self, id: &str) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string()); + /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). + /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. + pub(crate) fn grant_writes(&self) -> u64 { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes + } + + /// How many grant writes the process keeps ([`IgnoredGrants::written`]). + #[cfg(test)] + pub(crate) fn grant_writes_kept() -> usize { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len() + } + + /// Stop counting the attached store's grant for the plug-in `id`, in every session in the + /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in + /// one of them: for a grant that should be gone and could not be removed, which would otherwise + /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed + /// write: when a session has written the store's grants for `id` since, the file holds what that + /// write asked for, and the grant keeps counting. A write no longer among the latest + /// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the + /// folder reads `grants.json` as it is. + pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) { + if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + if shared.written.get(&entry).is_none_or(|at| *at <= writes) { + shared.ignored.insert(entry); + } + } } fn write_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 91c95cb630..7f3999a22e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -20,6 +20,7 @@ mod brushlib; mod brushsym; mod build; mod cancel; +mod caret_kerning; mod charstroke; pub(super) mod clip; mod clipboard; @@ -133,6 +134,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/appearance.rs b/crates/engine/src/tests/appearance.rs index 8d12c83719..53cbb42ca9 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; -use vectorcraft_doc::{AppearanceItem, FillLayer, Node}; +use vectorcraft_doc::{AppearanceItem, FillLayer, LineJoin, Node}; use crate::*; @@ -185,6 +185,48 @@ fn group_rows_are_the_groups_own_stack() { assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); } +/// #6739: `stroke.set` without an item on a group with a stroke row of its own edits that row; +/// its members, which have no stroke (as paths a PDF import brings in), are left alone instead of +/// each getting a black 1 pt stroke. Options alone never make a stroke visible: a row created for +/// a join has no paint, while a weight still gives the usual black stroke. +#[test] +fn stroke_options_on_a_group_with_its_own_stroke_edit_that_stroke() { + let mut s = session(); + let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 250.0)); + for id in [a, b] { + run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1})); + assert!(node(&s, id).appearance.stroke().is_none(), "the member has no stroke row"); + } + let g = NodeId(run(&mut s, "object.group", json!({"ids": [a.0, b.0]}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({"ids": [g.0]})); + run(&mut s, "appearance.setItem", json!({"ids": [g.0], "index": 0, "color": "#ffffff", "weight": 2})); + let r = run(&mut s, "stroke.set", json!({"ids": [g.0], "join": "round"})); + assert_eq!(r["objects"], 1, "{r}"); + let gs = node(&s, g).appearance.stroke().unwrap().clone(); + assert_eq!((gs.join, gs.width, gs.paint.color().map(|c| c.to_hex())), (LineJoin::Round, 2.0, Some("#ffffff".to_string()))); + for id in [a, b] { + assert!(node(&s, id).appearance.stroke().is_none(), "a member keeps no stroke"); + } + // A group without a stroke of its own still reaches its members; for a join alone the row + // made on each has no paint, so nothing becomes visible. + let (c, d) = (rect(&mut s, 400.0), rect(&mut s, 550.0)); + for id in [c, d] { + run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1})); + } + let h = NodeId(run(&mut s, "object.group", json!({"ids": [c.0, d.0]}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"ids": [h.0], "join": "bevel"})); + for id in [c, d] { + let st = node(&s, id).appearance.stroke().cloned().expect("a stroke row for the join"); + assert_eq!((st.paint, st.join), (Paint::None, LineJoin::Bevel), "no paint for a join alone"); + } + // A weight is a visible stroke: an object without one gets the black stroke, as before. + let e = rect(&mut s, 700.0); + run(&mut s, "appearance.removeItem", json!({"ids": [e.0], "index": 1})); + run(&mut s, "stroke.set", json!({"ids": [e.0], "weight": 3})); + let st = node(&s, e).appearance.stroke().cloned().unwrap(); + assert_eq!((st.paint, st.width), (Paint::solid(Color::BLACK), 3.0)); +} + #[test] fn text_items_take_item_paint() { let mut s = session(); diff --git a/crates/engine/src/tests/areafit.rs b/crates/engine/src/tests/areafit.rs index b6d6883430..7d8f759cf5 100644 --- a/crates/engine/src/tests/areafit.rs +++ b/crates/engine/src/tests/areafit.rs @@ -485,7 +485,7 @@ fn making_and_restyling_type_reports_its_overflow() { } /// The formats whose exports draw type. -const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"]; +const DRAWING_FORMATS: [&str; 9] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf", "hpgl"]; /// The warnings of `document.export {format, …options}` about type that doesn't fit its frame. fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> { diff --git a/crates/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/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..243ea564f4 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -117,13 +117,13 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[4]["count"].as_u64(), Some(4)); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -132,6 +132,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { for (id, kind) in [ ("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), + ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), ("game-trails", "art"), @@ -165,6 +166,77 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value +/// outside would be clamped without notice), names no other library or default brush uses; added +/// in one step, not twice, and saved as a library they reopen unchanged. +#[test] +fn the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges() { + use vectorcraft_brush::{Bristle, BristleShape}; + let mut s = session(); + let (_, lib) = cmd::brushlib::library(&s, "bristle-brushes").unwrap(); + assert_eq!(lib.name, "Bristle Brushes"); + let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert!((8..=10).contains(&names.len()), "{names:?}"); + let within = |v: f64, (lo, hi): (f64, f64)| (lo..=hi).contains(&v); + let mut shapes = vec![]; + for b in &lib.brushes { + let BrushKind::Bristle(x) = &b.kind else { panic!("{} is not a bristle brush", b.name) }; + assert!(within(x.size, Bristle::SIZE_RANGE) && within(x.length, Bristle::LENGTH_RANGE), "{}", b.name); + for v in [x.density, x.thickness, x.opacity, x.stiffness] { + assert!(within(v, Bristle::PERCENT_RANGE), "{}: {v}", b.name); + } + let mut kept = b.clone(); + kept.sanitize(); + assert_eq!(&kept, b, "sanitizing changes {}", b.name); + assert!(b.name.chars().count() <= Bristle::MAX_NAME_CHARS, "{}", b.name); + if !shapes.contains(&x.shape) { + shapes.push(x.shape); + } + } + let every = [ + BristleShape::RoundPoint, + BristleShape::FlatPoint, + BristleShape::RoundBlunt, + BristleShape::FlatBlunt, + BristleShape::RoundCurve, + BristleShape::FlatCurve, + BristleShape::RoundAngle, + BristleShape::FlatAngle, + BristleShape::RoundFan, + BristleShape::FlatFan, + ]; + assert!(every.iter().all(|x| shapes.contains(x)), "{shapes:?}"); + // Names of its own: no default brush's, no other built-in library's, none twice. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec<String> = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "bristle-brushes") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>()) + .collect(); + for (i, n) in names.iter().enumerate() { + assert!(!defaults.contains(&n.as_str()) && !others.contains(n) && !names[..i].contains(n), "{n}"); + } + + // Added to a document in one undo step; adding them again adds nothing. + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Bristle Brushes"})); + assert_eq!(strs(&r["added"]), names.iter().map(String::as_str).collect::<Vec<_>>()); + assert_eq!(undo_len(&s), before.0 + 1); + assert_eq!(brushes(&s), [before.1.clone(), names.clone()].concat()); + for b in &lib.brushes { + assert_eq!(&brush(&s, &b.name), b); + } + let again = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "bristle-brushes"})); + assert_eq!((strs(&r["added"]).len(), strs(&r["existing"]).len(), undo_len(&s)), (0, names.len(), again)); + // Saved as a library and loaded back: the same brushes, in the same order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Bristles" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Bristles.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.brushes, lib.brushes); +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index fd1ac70476..1a1fcd87b5 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -253,6 +253,367 @@ fn selection_art_is_the_art_new_brush_takes() { assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step"); } +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn selected(s: &Session) -> Vec<NodeId> { + s.doc().unwrap().selection.objects.clone() +} + +/// The JSON of object `id`, as `brush.options` params take art. +fn art_json(s: &mut Session, id: NodeId) -> Value { + s.execute("brush.selectionArt", &json!({"ids": [id.0]})).unwrap()["art"].clone() +} + +/// `brush.placeArt` places a copy of an art or scatter brush's art centered where it is dropped +/// (by default on the first artboard's center), selected, in one undo step; brushes without art, +/// unknown brushes and a position that is not a number are refused. +#[test] +fn place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped() { + let mut s = session(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + let steps = undo_steps(&s); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Bar", "x": 100, "y": 80})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(selected(&s), vec![id]); + assert_ne!(id, r, "a copy, not the art the brush was made from"); + assert!(close(bounds(&s, id), Rect::new(80.0, 76.0, 120.0, 84.0), 1e-6), "{:?}", bounds(&s, id)); + // Without a position: the first artboard's center (the document is 400 × 300). + let scatter = NodeId(s.execute("brush.placeArt", &json!({"name": "Dots"})).unwrap()["id"].as_u64().unwrap()); + let c = bounds(&s, scatter).center(); + assert!((c.x - 200.0).abs() < 1e-6 && (c.y - 150.0).abs() < 1e-6, "{c:?}"); + let steps = undo_steps(&s); + for (p, msg) in [ + (json!({"name": "3 pt. Round"}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Bristle Round"}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Nope"}), "no brush named `Nope`"), + ] { + let e = s.execute("brush.placeArt", &p).unwrap_err(); + assert!(e.to_string().contains(msg), "{p}: {e}"); + } + assert!(s.execute("brush.placeArt", &json!({"name": "Bar", "x": "100"})).is_err()); + assert_eq!(undo_steps(&s), steps, "a refused call adds no undo step"); +} + +/// Brush art holding an instance of a symbol whose art lies away from the symbol's origin (as +/// imported symbols can) is centered by that art, not by the box an instance has without its +/// document. +#[test] +fn placed_art_with_a_symbol_instance_is_centered_by_the_symbols_art() { + let mut s = session(); + let r = rect(&mut s, 300.0, 10.0, 100.0, 40.0); + let art = node(&s, r); + s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Off".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((50.0, 50.0)); + d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap(); + let inst = inst.0; + s.execute("brush.new", &json!({"type": "art", "name": "Stamp", "ids": [inst]})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Stamp", "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let st = s.doc().unwrap(); + let b = st.doc.bounds_of(&[id], false).unwrap(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}"); + assert!((b.width() - 100.0).abs() < 1e-6, "{b:?}"); +} + +/// A pattern brush with side, outer corner and start tiles, the side tile with a repeat frame +/// wider than its art: → its name. +fn framed_pattern_brush(s: &mut Session) -> String { + let side = rect(s, 0.0, 0.0, 20.0, 10.0); + let corner = rect(s, 0.0, 40.0, 10.0, 10.0); + let start = rect(s, 0.0, 80.0, 6.0, 10.0); + let name = s.execute("brush.new", &json!({"type": "pattern", "name": "Rail", "ids": [side.0]})).unwrap()["name"].as_str().unwrap().to_string(); + let params = json!({ + "side": art_json(s, side), + "side_frame": Rect::new(-2.0, 0.0, 22.0, 10.0), + "outer_corner": art_json(s, corner), + "start": art_json(s, start), + }); + s.execute("brush.options", &json!({"name": name, "params": params})).unwrap(); + name +} + +/// `brush.placeArt` lays a pattern brush's tiles out left to right, each in a group named for its +/// tile, with an unpainted rectangle at the back of a tile with a repeat frame marking that frame. +#[test] +fn pattern_brushes_place_their_tiles_in_a_row() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let placed = node(&s, id); + let tiles: Vec<Node> = placed.children().unwrap().iter().map(|c| (**c).clone()).collect(); + let names: Vec<&str> = tiles.iter().map(|t| t.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Side Tile", "Outer Corner Tile", "Start Tile"]); + let boxes: Vec<Rect> = tiles.iter().map(|t| t.geometric_bounds().unwrap()).collect(); + assert!(boxes.windows(2).all(|w| (w[1].x0 - w[0].x1 - 10.0).abs() < 1e-6), "10 pt apart, left to right: {boxes:?}"); + // The side tile: the frame's rectangle, unpainted, behind the art. + let side = tiles[0].children().unwrap(); + assert_eq!(side.len(), 2); + let marker = &side[0]; + assert!(marker.appearance.items.iter().all(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.is_none(), + vectorcraft_doc::AppearanceItem::Stroke(st) => st.paint.is_none(), + })); + let frame = marker.geometric_bounds().unwrap(); + assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.height() - 10.0).abs() < 1e-6, "{frame:?}"); + assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle"); +} + +/// `brush.replaceArt` reads the tiles `brush.placeArt` places back tile by tile, frames included: +/// every tile and the frame keep their sizes, and the frame keeps its place around the art. +#[test] +fn placed_pattern_tiles_go_back_into_the_brush_tile_by_tile() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let steps = undo_steps(&s); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [id.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let after = s.execute("brush.get", &json!({"name": name})).unwrap(); + let size = |v: &Value| { + let n: Node = serde_json::from_value(v.clone()).unwrap(); + let b = n.geometric_bounds().unwrap(); + (b.width(), b.height()) + }; + for key in ["side", "outer_corner", "start"] { + let (a, b) = (size(&before[key]), size(&after[key])); + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{key}: {a:?} → {b:?}"); + } + assert!(after.get("end").is_none_or(Value::is_null) && after.get("inner_corner").is_none_or(Value::is_null)); + let frame: Rect = serde_json::from_value(after["side_frame"].clone()).unwrap(); + let art: Node = serde_json::from_value(after["side"].clone()).unwrap(); + let art = art.geometric_bounds().unwrap(); + assert!((frame.width() - 24.0).abs() < 1e-6 && (art.x0 - frame.x0 - 2.0).abs() < 1e-6, "{frame:?} around {art:?}"); + assert!(after["outer_corner_frame"].is_null() || after.get("outer_corner_frame").is_none()); +} + +/// Art that is not the tiles `brush.placeArt` places becomes a pattern brush's side tile, and the +/// other tiles stay; one tile group alone replaces only that tile. +#[test] +fn other_art_replaces_the_side_tile_of_a_pattern_brush() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let r = rect(&mut s, 100.0, 100.0, 30.0, 12.0); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [r.0]})).unwrap(); + let after = s.execute("brush.get", &json!({"name": name})).unwrap(); + let side: Node = serde_json::from_value(after["side"].clone()).unwrap(); + assert!(close(side.geometric_bounds().unwrap(), Rect::new(100.0, 100.0, 130.0, 112.0), 1e-6)); + assert!(after.get("side_frame").is_none_or(Value::is_null), "the old frame goes with the old art"); + assert_eq!((&after["outer_corner"], &after["start"]), (&before["outer_corner"], &before["start"])); + // One placed tile, its group dragged back alone: only the start tile changes. + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()); + let start = node(&s, id).children().unwrap()[2].clone(); + assert_eq!(start.name.as_deref(), Some("Start Tile")); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [start.id.0]})).unwrap(); + let last = s.execute("brush.get", &json!({"name": name})).unwrap(); + assert_eq!((&last["side"], &last["outer_corner"]), (&after["side"], &after["outer_corner"])); + let art: Node = serde_json::from_value(last["start"].clone()).unwrap(); + assert!(close(art.geometric_bounds().unwrap(), start.geometric_bounds().unwrap(), 1e-6), "the start tile is the placed one"); + assert!(art.name.is_none(), "the tile's name stays on the canvas"); +} + +/// `brush.replaceArt` gives a brush new art in one undo step: strokes that use it redraw with it, +/// or with `applyToStrokes: false` keep the brush as it was while a copy with the new art becomes +/// the current brush. A brush no stroke uses changes in place either way. +#[test] +fn replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush() { + let mut s = session(); + let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + let path = line(&mut s); + s.execute("brush.apply", &json!({"name": "Bar", "ids": [path.0]})).unwrap(); + let thin = brushed_bounds(&s, path).height(); + // A path painted with a brush made from the taller art: what the stroke should draw. + let tall = rect(&mut s, 10.0, 220.0, 40.0, 20.0); + s.execute("brush.new", &json!({"type": "art", "name": "Tall", "ids": [tall.0]})).unwrap(); + let reference = line(&mut s); + s.execute("brush.apply", &json!({"name": "Tall", "ids": [reference.0]})).unwrap(); + let want = brushed_bounds(&s, reference).height(); + assert!(want > thin * 2.0, "{want} against {thin}"); + // Apply to Strokes: the stroke redraws with the taller art. + let steps = undo_steps(&s); + let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [tall.0]})).unwrap(); + assert_eq!(r["name"], "Bar"); + assert_eq!(undo_steps(&s), steps + 1); + assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((brushed_bounds(&s, path).height() - thin).abs() < 1e-9, "undo puts the old art back"); + s.execute("edit.redo", &json!({})).unwrap(); + // Leave Strokes: the stroke keeps Bar as it was; Bar copy has the new art, after Bar, current. + let short = rect(&mut s, 10.0, 260.0, 40.0, 4.0); + let bar = s.execute("brush.get", &json!({"name": "Bar"})).unwrap(); + let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [short.0], "applyToStrokes": false})).unwrap(); + assert_eq!(r["name"], "Bar copy"); + assert_eq!(brush_of(&s, path).as_deref(), Some("Bar")); + assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap(), bar); + assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6); + let copy: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Bar copy"})).unwrap()["art"].clone()).unwrap(); + assert!((copy.geometric_bounds().unwrap().height() - 4.0).abs() < 1e-6); + let list = s.execute("brush.list", &json!({})).unwrap(); + assert_eq!(list["current"], "Bar copy"); + let names: Vec<&str> = list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect(); + let at = names.iter().position(|n| *n == "Bar").unwrap(); + assert_eq!(names.get(at + 1), Some(&"Bar copy")); + // No stroke uses Bar copy: its art changes in place, with or without applying. + s.execute("brush.replaceArt", &json!({"name": "Bar copy", "ids": [tall.0], "applyToStrokes": false})).unwrap(); + assert!(s.execute("brush.get", &json!({"name": "Bar copy 2"})).is_err()); +} + +/// `brush.replaceArt` refuses a brush without art, an unknown brush, an empty selection, art with no +/// width and an `applyToStrokes` that is not a boolean, and changes nothing then. +#[test] +fn replace_art_refuses_what_it_cannot_take() { + let mut s = session(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + let flat = s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap(); + let lib = s.execute("brush.list", &json!({})).unwrap(); + let steps = undo_steps(&s); + for (p, msg) in [ + (json!({"name": "3 pt. Round", "ids": [r.0]}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Bristle Round", "ids": [r.0]}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Nope", "ids": [r.0]}), "no brush named `Nope`"), + (json!({"name": "Arrow", "ids": [flat]}), "the selected art has no width"), + (json!({"name": "Chain", "ids": [flat]}), "the selected art has no width"), + (json!({"name": "Arrow", "ids": [r.0], "applyToStrokes": "no"}), "`applyToStrokes` must be true or false"), + ] { + let e = s.execute("brush.replaceArt", &p).unwrap_err(); + assert!(e.to_string().contains(msg), "{p}: {e}"); + } + s.select(|_, sel| sel.clear()).unwrap(); + let e = s.execute("brush.replaceArt", &json!({"name": "Arrow"})).unwrap_err(); + assert!(e.to_string().contains("nothing selected"), "{e}"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("brush.list", &json!({})).unwrap(), lib); +} + +/// An instance of a symbol whose art has no width is art with no width for `brush.new` and +/// `brush.replaceArt`, although an instance's box without its document has width. +#[test] +fn a_symbol_whose_art_has_no_width_makes_no_brush_art() { + let mut s = session(); + let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap()); + let art = node(&s, flat); + s.execute("edit.clear", &json!({"ids": [flat.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Flat".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let inst = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Flat".into(), xf: vectorcraft_geom::Affine::IDENTITY })) + .unwrap(); + assert!(node(&s, inst).geometric_bounds().is_some_and(|b| b.width() > 1.0), "the box an instance has without its document"); + for (cmd, p) in [("brush.new", json!({"type": "art", "ids": [inst.0]})), ("brush.replaceArt", json!({"name": "Arrow", "ids": [inst.0]}))] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(e.to_string().contains("the selected art has no width"), "{cmd}: {e}"); + } +} + +/// `brush.replaceArt` checks only the art it is given: a tile the pattern brush already has that +/// has no width does not stop another tile from being replaced. +#[test] +fn replace_art_checks_only_the_tiles_it_is_given() { + let mut s = session(); + let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap()); + let end = art_json(&mut s, flat); + s.execute("brush.options", &json!({"name": "Chain", "params": {"end": end}})).unwrap(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + s.execute("brush.replaceArt", &json!({"name": "Chain", "ids": [r.0]})).unwrap(); + let side: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Chain"})).unwrap()["side"].clone()).unwrap(); + assert!(close(side.geometric_bounds().unwrap(), bounds(&s, r), 1e-6)); +} + +/// Tile `key` (`side`, `start`…) of pattern brush `name`. +fn tile_art(s: &mut Session, name: &str, key: &str) -> Node { + serde_json::from_value(s.execute("brush.get", &json!({"name": name})).unwrap()[key].clone()).unwrap() +} + +/// A pattern brush tile placed with `brush.placeArt` and given back with `brush.replaceArt` is the +/// art it was, not that art inside the tile's group, however many times it goes out and back. +#[test] +fn pattern_tiles_go_out_and_back_without_gaining_a_group() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let size = |n: &Node| { + let b = n.geometric_bounds().unwrap(); + (b.width(), b.height()) + }; + let first = tile_art(&mut s, &name, "side"); + assert!(matches!(first.kind, NodeKind::Path { .. }), "{:?}", first.kind); + for round in 1..=2 { + let id = s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [id]})).unwrap(); + let side = tile_art(&mut s, &name, "side"); + assert!(matches!(side.kind, NodeKind::Path { .. }), "round {round}: {:?}", side.kind); + let (a, b) = (size(&first), size(&side)); + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "round {round}: {a:?} → {b:?}"); + } +} + +/// A tile group whose art was deleted, leaving only the rectangle that marks its frame, is art with +/// no width: the brush keeps its tile. +#[test] +fn a_tile_group_holding_only_its_frame_is_refused() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()); + let side = node(&s, id).children().unwrap()[0].clone(); + assert_eq!(side.name.as_deref(), Some("Side Tile")); + let art = side.children().unwrap()[1].id; + s.execute("edit.clear", &json!({"ids": [art.0]})).unwrap(); + let steps = undo_steps(&s); + let e = s.execute("brush.replaceArt", &json!({"name": name, "ids": [side.id.0]})).unwrap_err(); + assert!(e.to_string().contains("the selected art has no width"), "{e}"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("brush.get", &json!({"name": name})).unwrap(), before); +} + +/// Two groups named for the same tile: the frontmost one is the tile's art. +#[test] +fn the_frontmost_group_named_for_a_tile_is_that_tile() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let back = rect(&mut s, 10.0, 10.0, 20.0, 10.0); + let front = rect(&mut s, 10.0, 40.0, 30.0, 10.0); + let (back, front) = (node(&s, back), node(&s, front)); + s.execute("edit.clear", &json!({"ids": [back.id.0, front.id.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + let tile = |d: &mut Document, n: Node| { + let mut g = Node::group(d.alloc_id(), vec![std::sync::Arc::new(n)]); + g.name = Some("Start Tile".into()); + std::sync::Arc::new(g) + }; + let tiles = vec![tile(d, back), tile(d, front)]; + let both = Node::group(d.alloc_id(), tiles); + let both_id = both.id; + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, both).unwrap(); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [both_id.0]})).unwrap(); + let start = tile_art(&mut s, &name, "start"); + assert!((start.geometric_bounds().unwrap().width() - 30.0).abs() < 1e-6, "{:?}", start.geometric_bounds()); +} + +/// Type is art for `brush.replaceArt` as it is for `brush.new`: taken as it is. +#[test] +fn replace_art_takes_type_as_brush_new_does() { + let mut s = session(); + let t = s.execute("text.create", &json!({"x": 20, "y": 60, "text": "Hi", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let want = art_json(&mut s, NodeId(t)); + s.execute("brush.replaceArt", &json!({"name": "Arrow", "ids": [t]})).unwrap(); + assert_eq!(s.execute("brush.get", &json!({"name": "Arrow"})).unwrap()["art"], want); + let made = s.execute("brush.new", &json!({"type": "art", "ids": [t]})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.execute("brush.get", &json!({"name": made})).unwrap()["art"], want); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); diff --git a/crates/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/caret_kerning.rs b/crates/engine/src/tests/caret_kerning.rs new file mode 100644 index 0000000000..dca3519cb1 --- /dev/null +++ b/crates/engine/src/tests/caret_kerning.rs @@ -0,0 +1,183 @@ +//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line +//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before +//! the character after it (`kerningBefore`), as one undo step, and the character moves. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, t: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") }; + (**t).clone() +} + +fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle { + vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1) +} + +/// Where glyph `byte` is drawn along its line, in text space. +fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 { + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id)); + l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4] +} + +/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`. +fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo { + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap(); + } + assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text"); + s.set_tool_option("select", &json!({"start": caret, "end": caret})); + v +} + +fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value { + s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap() +} + +#[test] +fn a_step_at_the_start_of_the_text_moves_the_first_character() { + let mut s = session(); + let id = text(&mut s, "AV"); + let inc = s.prefs.tracking_increment; + let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1)); + assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0])); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it"); + // A step of `inc` thousandths of the 20 pt em moves both letters along the line. + let moved = inc / 1000.0 * 20.0; + assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6); + // A negative step goes back towards the line's start, past it. + step_at(&mut s, id, 0, -3); + assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc); + assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 0.0); + assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6); +} + +#[test] +fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() { + let mut s = session(); + let id = text(&mut s, "AV"); + step_at(&mut s, id, 1, 1); + let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1)); + assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None)); + assert!(a.kerning.is_some(), "the pair after A"); +} + +#[test] +fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "One\nTwo"); + let t = drawn_x(&s, id, 4); + step_at(&mut s, id, 4, 1); + assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning"); + assert!(drawn_x(&s, id, 4) > t, "T moves"); + let forced = text(&mut s, "One\u{2028}Two"); + let after = "One\u{2028}".len(); + step_at(&mut s, forced, after, 1); + assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc)); +} + +#[test] +fn an_empty_line_has_nothing_to_kern() { + let mut s = session(); + let id = text(&mut s, "One\n\nTwo"); + let before = obj(&s, id).runs; + assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([])); + assert_eq!(obj(&s, id).runs, before); + // Nor does the end of text after a line break. + let end = text(&mut s, "One\n"); + assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([])); +} + +#[test] +fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap(); + step_at(&mut s, id, 0, 2); + assert_ne!(style_at(&s, id, 0).kerning_before, 0.0); + // At the caret before A only the space before A goes; A keeps its kerning after it. + caret_in(&mut s, id, 0); + s.execute("type.resetTracking", &json!({})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0)); + step_at(&mut s, id, 0, 2); + s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning"); +} + +#[test] +fn set_range_style_sets_the_space_before() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 50.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err()); +} + +/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back. +#[test] +fn alt_arrows_at_the_caret_before_the_first_character_kern_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "Hello"); + let v = caret_in(&mut s, id, 0); + let alt = Mods { alt: true, ..Mods::default() }; + s.tool_key(ToolKey::Left, alt, v).unwrap(); + assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None)); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); +} + +/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character +/// with a space before it leaves the target's first character where it was. +#[test] +fn the_eyedropper_keeps_the_targets_space_before() { + let mut s = session(); + let src = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap(); + let dst = text(&mut s, "Target"); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let st = style_at(&s, dst, 0); + assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't"); +} + +/// A character style captured from a line's first character leaves its space before out, and a +/// style can't be given one: it would space the first character of every paragraph set in it. +#[test] +fn a_character_style_leaves_the_space_before_out() { + let mut s = session(); + let id = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap(); + let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone(); + assert_eq!(attrs.get("tracking"), Some(&json!(50.0))); + assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}"); + let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err(); + assert!(err.to_string().contains("kerningBefore"), "{err}"); +} diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 278d979ef5..c5ef8a1949 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -432,6 +432,30 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { assert_eq!(overrides(&s, inst), 0); } +#[test] +fn redefining_the_symbol_drops_paints_its_new_objects_do_not_draw() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let p = json!({"id": inst.0, "path": [1], "fill": "#ff0000", "stroke": "#ff0000", "opacity": 50}); + s.execute("symbol.instance.setAppearance", &p).unwrap(); + // The second object is now an image, which draws no fill or stroke of its own. + let a = square(&mut s, 10.0); + place_image(&mut s); + let image = top(&s); + s.execute("symbol.update", &json!({"name": "Pair", "ids": [a.0, image.0]})).unwrap(); + let o = node(&s, inst).extras().symbol_overrides.clone(); + assert_eq!(o.len(), 1, "the opacity change stays: {o:?}"); + assert_eq!((&o[0].fill, &o[0].stroke, o[0].opacity), (&None, &None, Some(0.5))); + // A change of paint alone goes with its paint: the first object is now a blend. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [0], "stroke": "#ff0000"})).unwrap(); + let (b, c) = (square(&mut s, 10.0), square(&mut s, 60.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [b.0, c.0], "steps": 1})).unwrap()); + let d = square(&mut s, 120.0); + s.execute("symbol.update", &json!({"name": "Pair", "ids": [blend.0, d.0]})).unwrap(); + let o = node(&s, inst).extras().symbol_overrides.clone(); + assert_eq!(o.iter().map(|o| o.path.clone()).collect::<Vec<_>>(), [vec![1]], "{o:?}"); +} + #[test] fn replace_symbol_drops_the_changes_made_for_the_old_symbol() { let mut s = session(); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index ae1a72fa08..20cad1dc01 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -234,6 +234,26 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +#[test] +fn release_type_on_a_path_keeps_the_drawn_size() { + let mut s = session(); + let id = on_line(&mut s, "Release"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let size = 12.0 * fit(&s, id); + assert!(size > 24.0, "the text grows on the long line: {size}"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap(); + let released = NodeId(r["texts"][0].as_u64().unwrap()); + let t = text(&s, released); + assert!(!t.dynamic_text, "point type does not keep Dynamic Text"); + assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size); + assert_eq!(fit(&s, released), 1.0); + // Type on a path without Dynamic Text keeps its stored size. + let plain = on_line(&mut s, "Plain"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap(); + let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap())); + assert!(!t.dynamic_text && t.runs[0].style.size == 12.0); +} + /// 12 pt point type at (100, 100), selected, with its layout bounds. fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) { let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap(); diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index c37cbfe90d..4d86a84b3c 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["description"], "A poster\nfor the fair"); assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); - assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries"); + assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file"); assert_eq!(back["copyrightStatus"], "copyrighted"); assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); assert_eq!(back["copyrightUrl"], "https://example.com/rights"); @@ -277,6 +278,192 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() { assert_eq!(m.copyright_url, ""); } +/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose +/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`. +fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; + let first = first_extra(1); + let mut objects = vec![]; + let mut catalog = String::new(); + if let Some(t) = xmp_title { + let xmp = format!( + "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\ + <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\ + <dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Zugangswege</rdf:li><rdf:li xml:lang=\"x-default\">{t}</rdf:li></rdf:Alt></dc:title>\ + </rdf:Description></rdf:RDF></x:xmpmeta>" + ); + objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())); + catalog = format!("/Metadata {first} 0 R "); + } + let info_at = first + objects.len(); + if let Some(i) = info { + objects.push(format!("<< {i} >>")); + } + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None); + if info.is_none() { + return bytes; + } + // The trailer follows the cross-reference table, so a longer trailer moves no object. + String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes() +} + +/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab +/// shows the document's name), `s` holding it. +fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() +} + +/// The document's name (its tab) and its File Info title. +fn titles(s: &mut Session) -> [String; 2] { + let info = s.execute("file.info", &json!({})).unwrap(); + [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()] +} + +/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title. +fn opened(name: &str, bytes: &[u8]) -> [String; 2] { + let mut s = Session::new(); + let r = open_named(&mut s, name, bytes); + let t = titles(&mut s); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply"); + assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title"); + t +} + +fn svg_titled(inner: &str) -> String { + format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" + ) +} + +#[test] +fn import_puts_the_files_own_title_in_file_info_only() { + // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the + // File Info title; the document keeps the file's name, with its extension. + assert_eq!(opened("box.svg", svg_titled("<title>\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]); + assert_eq!( + opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), + ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"] + ); + // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone. + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]); + assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]); + // Compressed SVG too. + let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner)); + assert_eq!(opened("box.svgz", &svgz("Access paths")), ["box.svgz", "Access paths"]); + // A file that gives none (or a blank one) gives File Info the document's name. + assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]); + // The Untitled-N name New gives is no title (a document that never had one writes it). + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]); + // A template opens as a new untitled document, whatever title its file gave. + let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None)); + assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}"); +} + +#[test] +fn imported_svg_titles_are_filtered_and_bounded() { + for (raw, expected) in [ + ("Untitled-2".to_string(), None), + ("\u{7f}".to_string(), None), + ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())), + ("λ".repeat(65_537), Some("λ".repeat(65_536))), + ] { + let svg = svg_titled(&format!("{raw}")); + for format in ["svg", "svgz"] { + let name = format!("box.{format}"); + let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() }; + assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}"); + } + } +} + +#[test] +fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() { + let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("box.svg"); + std::fs::write(&path, svg_titled("Access paths")).unwrap(); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + // The default names of what the document exports are made from the file's name, not its title. + assert_eq!(s.doc().unwrap().doc.title, "box.svg"); + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_info_edits_a_files_own_title_and_the_document_follows() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The dialog sends back the title it was given: nothing changes (no undo step). + let before = s.doc().unwrap().revision; + let shown = s.execute("file.info", &json!({})).unwrap(); + s.execute("file.info", &shown).unwrap(); + assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit"); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); + // Another title is the document's own, as ever, and the file's is gone. + let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap(); + assert_eq!(r["title"], "Plan B"); + assert_eq!(titles(&mut s), ["Plan B", "Plan B"]); + assert_eq!(s.doc().unwrap().doc.metadata.title, None); + // Undo brings the file's title back, and the name. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); +} + +#[test] +fn restored_file_info_title_keeps_authored_spaces() { + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into()); + for format in ["vectorcraft", "svg", "pdf"] { + let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap()); + let mut reopened = Session::new(); + let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes); + assert_eq!(reply["restored"], format != "vectorcraft", "{format}"); + assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}"); + assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}"); + } +} + +#[test] +fn the_files_own_title_is_kept_by_saves_and_written_by_exports() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The native file: its own title is the document's name, the File Info title survives. + let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()); + let mut t = Session::new(); + open_named(&mut t, "other.vectorcraft", &native); + assert_eq!(titles(&mut t), ["box.svg", "Access paths"]); + // An SVG with the editing data it carries, reopened under another name. + let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"] + .as_str() + .unwrap() + .to_string(); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + let mut u = Session::new(); + let r = open_named(&mut u, "again.svg", svg.as_bytes()); + assert_eq!(r["restored"], true); + assert_eq!(titles(&mut u), ["again.svg", "Access paths"]); + // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer. + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf)); + assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}"); + } + // PNG text. + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths")); + // The default file names are made from the document's name. + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf"); +} + #[test] fn png_exports_carry_text_chunks() { let mut s = session(); diff --git a/crates/engine/src/tests/gradpresets.rs b/crates/engine/src/tests/gradpresets.rs index 437f96d26c..4328361b8b 100644 --- a/crates/engine/src/tests/gradpresets.rs +++ b/crates/engine/src/tests/gradpresets.rs @@ -38,7 +38,19 @@ 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", + "Holographic", + "Duotone Gradients", + "Radial Glows", + "Metallic Gradients" + ] + ); let mut seen = std::collections::HashSet::new(); assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); let vignette = rows.iter().find(|r| r["name"] == "Vignette").unwrap(); @@ -65,6 +77,56 @@ 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_holographic_foil_applies_in_place_as_one_undo_step() { + let (mut s, id) = session_with(json!({"gradient": {"angle": 30}})); + let before = paint(&s, id, false); + let undo = s.doc().unwrap().history.undo.len(); + let veil = s.gradient_preset("prism veil").unwrap(); + assert_eq!((veil.library, veil.gradient.method), (Some("Holographic"), GradientMethod::Perceptual)); + let r = s.execute("gradient.presets.apply", &json!({"name": "prism veil"})).unwrap(); + assert_eq!(r["name"].as_str(), Some("Prism Veil")); + let g = paint(&s, id, false); + assert_eq!((g.gradient.kind, g.gradient.method, &g.gradient.stops), (GradientKind::Linear, GradientMethod::Perceptual, &veil.gradient.stops)); + assert_eq!(g.gradient.stops.len(), 6); + assert_eq!(g.angle, before.angle, "the placement stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.gradient_preset_name(&g.gradient).as_deref(), Some("Prism Veil")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paint(&s, id, false), before); +} + +/// A preset saved as "Mint Halo" before Holographic existed stays listed under a free name. +#[test] +fn a_saved_preset_named_like_a_holographic_glow_gets_a_free_name() { + let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}})); + let halo = s.gradient_preset("Mint Halo").unwrap().gradient; + let mut own = paint(&s, id, false).gradient; + own.dither = true; + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = vec![crate::cmd::GradientPreset { name: "MINT HALO".into(), gradient: own.clone() }]; + s.apply_prefs(prefs); + assert_eq!(s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect::>(), ["MINT HALO 2"]); + s.execute("gradient.presets.apply", &json!({"name": "mint halo 2"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient, own); + s.execute("gradient.presets.apply", &json!({"name": "Mint Halo"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient.stops, halo.stops); +} + #[test] fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() { let (mut s, id) = session_with(json!({"color": "#336699"})); @@ -334,3 +396,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/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 1e13c468c3..24a1635c30 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -94,10 +94,49 @@ fn run_fresh( params: &serde_json::Value, ) -> (std::result::Result, Option>) { let (mut s, ..) = doc_with_type(); - let r = s.execute(cmd, params).map_err(|e| e.to_string()); + let r = s.execute(cmd, params).map(|v| without_plugin_effects(cmd, v)).map_err(|e| e.to_string()); (r, s.doc().ok().map(|d| d.doc.clone())) } +/// `cmd`'s result with the plug-in effects (`plugin.`) taken out of `effect.list`'s catalog. +/// The plug-in tests install and remove plug-ins in the process-wide registry while these +/// comparisons run, so two runs of `effect.list` can list different plug-ins. +fn without_plugin_effects(cmd: &str, mut r: serde_json::Value) -> serde_json::Value { + if cmd == "effect.list" + && let Some(catalog) = r.get_mut("catalog").and_then(serde_json::Value::as_array_mut) + { + catalog.retain(|e| !e["id"].as_str().is_some_and(|id| id.starts_with(vectorcraft_plugins::PREFIX))); + } + r +} + +/// Whether two results of `cmd` are the same, apart from the plug-in effects in `effect.list`'s +/// catalog ([`without_plugin_effects`]). +fn same_without_plugin_effects(cmd: &str, x: &serde_json::Value, y: &serde_json::Value) -> bool { + if cmd == "effect.list" { without_plugin_effects(cmd, x.clone()) == without_plugin_effects(cmd, y.clone()) } else { x == y } +} + +/// Two `effect.list` results that differ only in a plug-in effect in the catalog compare the same; +/// a difference in a built-in effect, in `applied` or in `activeItem`, or in another command's +/// result, does not. +#[test] +fn plugin_effects_are_left_out_of_the_effect_list_comparison() { + let list = |catalog: &[&str], applied: serde_json::Value, active: serde_json::Value| { + let catalog: Vec<_> = catalog.iter().map(|id| json!({"id": id, "label": id})).collect(); + json!({"catalog": catalog, "applied": applied, "activeItem": active}) + }; + let base = list(&["stylize.dropShadow"], json!([]), json!(null)); + let with_plugin = list(&["stylize.dropShadow", "plugin.test.glow"], json!([]), json!(null)); + assert!(same_without_plugin_effects("effect.list", &base, &with_plugin)); + assert!(same_without_plugin_effects("effect.list", &with_plugin, &base)); + assert_eq!(without_plugin_effects("effect.list", with_plugin.clone()), base); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&[], json!([]), json!(null))), "a built-in effect"); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([{"id": 3}]), json!(null))), "applied"); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([]), json!(0))), "activeItem"); + assert!(!same_without_plugin_effects("object.transform", &base, &with_plugin), "another command"); + assert_eq!(without_plugin_effects("object.transform", with_plugin.clone()), with_plugin); +} + /// Every command whose params description names both `id` and `ids` (`{ids?|id?}`, `{id?, ids?: /// … (instead of id)}`), with nothing selected: `id` does what `ids` of that one object does (the /// same result or the same error, and the same document), for objects of the document and for an @@ -265,7 +304,7 @@ fn ids_and_selection(cmd: &str, picks: &[usize]) -> std::result::Result x == y, + (Ok(x), Ok(y)) => same_without_plugin_effects(cmd, x, y), (Err(x), Err(y)) => x.to_string() == y.to_string(), _ => false, }; diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 9fc790efa2..0cc01bdae5 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -424,6 +424,93 @@ fn blend_replace_spine_follows_path() { assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}"); } +/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine, +/// not only the key's selection outline. +#[test] +fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap()); + sel(&mut s, &[g, arc]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + // Direct Selection picks the key inside the blend; the drag moves it. + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}"); + // Undo puts the key back on the spine as Replace Spine left it. + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + +/// #6809 on a closed spine, where both end keys start on its first point: moving the first key +/// moves its end of the spine without dragging the last key's end along. +#[test] +fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + +/// #6809 on a closed spine pinned by a spine edit: the end keys get points of their own, so +/// moving the first key afterwards still moves only its end. +#[test] +fn moving_the_first_key_of_a_pinned_closed_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + // Dragging a corner away from the keys pins the spine to them. + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 2, "x": 320, "y": 320})).unwrap(); + let NodeKind::Blend { spec, .. } = node(&s, g).kind else { panic!() }; + assert_eq!(spec.key_anchors.len(), 2, "{:?}", spec.key_anchors); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); +} + #[test] fn blend_align_to_path_rotates_steps() { let mut s = session(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index fccbe4a2d3..44a0ddde1b 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] @@ -4457,6 +4489,41 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on( assert_eq!(owners(&mut s)[4..], [Value::Null, json!(1)]); } +/// The rulers' origin dragged out of their corner snaps as a drawn point does: onto a shape's +/// corner and edge, the artboard's centre, and with Snap to Point along a ruler guide; with +/// nothing on it stays at the pointer. The drag changes nothing, and its guides go when it ends. +#[test] +fn the_ruler_origin_dragged_from_the_corner_snaps_as_a_drawn_point() { + use vectorcraft_geom::Point; + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + let (n, doc) = (undo_len(&s), s.doc().unwrap().doc.clone()); + let v = ViewInfo::default(); + let at = |s: &mut Session, x, y| s.ruler_origin_drag(Some(Point::new(x, y)), v).unwrap(); + assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0), "the corner"); + assert!(!s.overlays(v).is_empty(), "the guide showing it"); + assert_eq!(at(&mut s, 130.0, 202.0), Point::new(130.0, 200.0), "the bottom edge"); + assert_eq!(at(&mut s, 398.0, 301.5), Point::new(400.0, 300.0), "the artboard's centre"); + assert_eq!(at(&mut s, 302.0, 50.0).x, 300.0, "along the ruler guide"); + assert_eq!(s.ruler_origin_drag(None, v), None, "off the canvas"); + assert!(s.overlays(v).is_empty()); + assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0)); + s.end_ruler_origin_drag(); + assert!(s.overlays(v).is_empty()); + assert_eq!((undo_len(&s), &*s.doc().unwrap().doc), (n, &*doc), "nothing changed"); + // Nothing on: it stays at the pointer. + let off = ViewInfo { smart_guides: false, snap_to_point: false, ..v }; + assert_eq!(s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), off), Some(Point::new(101.5, 98.5))); + s.end_ruler_origin_drag(); + // Snap to Grid: onto a gridline crossing. + let grid = ViewInfo { snap_to_grid: true, ..v }; + let p = s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), grid).unwrap(); + s.end_ruler_origin_drag(); + assert_eq!(p, Point::new(p.x.round(), p.y.round()), "{p:?}"); + assert_ne!(p, Point::new(101.5, 98.5)); +} + /// Guides saved before guides had layers are put on a layer when the document opens. #[test] fn guides_from_older_files_are_put_on_a_layer() { diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index 01550b5ad1..c1da365740 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +/// Packaged for a client whose read root holds neither the document's file nor its links: the report, +/// its Document Info part included, names them by their file names, and `document.info` gives no +/// link. The person's report names them whole. +#[test] +fn a_confined_package_names_files_outside_the_read_root_by_name() { + let dir = Folder::new("package-roots"); + let mut s = poster(&dir); + std::fs::create_dir_all(dir.file("in")).unwrap(); + std::fs::remove_file(dir.file("art/blue.png")).unwrap(); + open(&mut s, &dir.file("poster.vectorcraft")); + let report = |r: &Value| { + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap() + }; + let person = report(&run(&mut s, "file.package", json!({}))); + assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}"); + assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}"); + assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}"); + + let in_root = dir.0.join("in"); + let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap(); + let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let text = report(&client); + assert!(text.contains("From: poster.vectorcraft\n"), "{text}"); + assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}"); + let base = dir.0.to_string_lossy().into_owned(); + assert!(!text.contains(&base), "{text}"); + for key in ["missingLinks", "warnings", "files"] { + assert!(!client[key].to_string().contains(&base), "{key}: {client}"); + } + // Document Info, whose report the package holds, gives no link either. + for p in [json!({}), json!({"format": "text"})] { + let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone())); + assert!(!info.to_string().contains(&base), "{p}: {info}"); + } + let info = run(&mut s, "document.info", json!({"format": "text"})); + assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}"); +} + +/// A missing link stored as a path relative to the document names a file inside a confined +/// request's read root: the package report and Document Info give its stored path. +#[test] +fn a_relative_link_inside_the_read_root_keeps_its_path() { + let dir = Folder::new("package-roots-relative"); + write(&dir.file("art/near.png"), &png(8, 8, RED)); + let mut s = session(); + let id = place(&mut s, &dir.file("art/near.png")); + let mut doc = (*s.doc().unwrap().doc).clone(); + let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") }; + im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png")); + write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false)); + // Missing, the link keeps its stored path when the document opens (the package copies the + // image the document holds). + std::fs::remove_file(dir.file("art/near.png")).unwrap(); + open(&mut s, &dir.file("near.vectorcraft")); + let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap(); + assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}"); + let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({}))); + let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect(); + assert_eq!(links, [&json!("art/near.png")], "{info}"); + let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"}))); + assert!(text.to_string().contains("art/near.png"), "{text}"); +} + +/// A link whose stored path names a place outside a confined request's read root, with its file +/// found by name beside the document inside the root: the package report and reply give the file +/// name, not the stored path. +#[test] +fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() { + let dir = Folder::new("package-roots-stale"); + write(&dir.file("in/near.png"), &png(8, 8, RED)); + let mut s = session(); + let id = place(&mut s, &dir.file("in/near.png")); + let mut doc = (*s.doc().unwrap().doc).clone(); + let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") }; + let stale = dir.file("out/near.png"); + im.link = Some(vectorcraft_doc::LinkInfo::new(&stale)); + write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false)); + // Missing when the document opens, the link keeps its stored path; the file then appears beside it. + std::fs::remove_file(dir.file("in/near.png")).unwrap(); + open(&mut s, &dir.file("in/near.vectorcraft")); + write(&dir.file("in/near.png"), &png(8, 8, RED)); + let in_root = dir.0.join("in"); + let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap(); + assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}"); + assert!(!text.contains(&stale), "{text}"); + for key in ["missingLinks", "warnings", "files"] { + assert!(!r[key].to_string().contains(&stale), "{key}: {r}"); + } +} + /// A file missing from a document that moved keeps the place its relative path names from the /// document's folder: the packaged document writes its relative path from there. #[test] diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index b0345db4ff..6850715b0e 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() { } } +/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such, +/// naming its key, not as a missing id; an id left out is still a missing one. +#[test] +fn a_malformed_object_id_is_not_reported_as_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + let points = json!([[0, 0], [10, 10], [20, 0]]); + for bad in [json!("5"), json!(-1)] { + let id = || json!({ "id": bad }); + let with = |extra: serde_json::Value| { + let mut p = id(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + for (cmd, p, key) in [ + ("stroke.widthPoint.set", id(), "id"), + ("stroke.widthPoint.copy", id(), "id"), + ("stroke.widthPoint.remove", id(), "id"), + ("path.curvatureEdit", id(), "id"), + ("path.removeAnchor", id(), "id"), + ("path.convertAnchor", id(), "id"), + ("path.reshapeSegment", id(), "id"), + ("path.split", id(), "id"), + ("path.appendAnchor", id(), "id"), + ("path.close", id(), "id"), + ("path.setHandle", id(), "id"), + ("path.moveAnchor", id(), "id"), + ("path.setAnchors", id(), "id"), + ("path.reshape", id(), "id"), + ("path.insertAnchor", id(), "id"), + ("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"), + ("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"), + ("document.node", id(), "id"), + ("layer.target", id(), "id"), + ("layer.setCurrent", id(), "id"), + ("layer.selectAll", id(), "id"), + ("layer.move", with(json!({"target": 1})), "id"), + ("layer.move", json!({"ids": [1], "target": bad}), "target"), + ("node.move", id(), "id"), + ("node.move", json!({"id": 1, "parent": bad}), "parent"), + ("text.reshapeArea", with(json!({"anchors": []})), "id"), + ("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"), + ("text.reshapePath", id(), "id"), + ("text.setPathHandle", id(), "id"), + ("text.editRange", id(), "id"), + ("text.startList", id(), "id"), + ("text.getRange", id(), "id"), + ("text.setGlyphAlternate", id(), "id"), + ("text.glyphAlternates", id(), "id"), + ("text.touchType", id(), "id"), + ("text.discardEmpty", id(), "id"), + ("text.createInPath", json!({"path": bad}), "path"), + ("object.envelope.setMeshPoint", id(), "id"), + ("object.mesh.setPointColor", with(json!({"index": 0})), "id"), + ("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"), + ("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"), + ("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"), + ("object.mesh.deletePoint", with(json!({"index": 0})), "id"), + ("object.slice.setRect", id(), "id"), + ("appearance.transfer", json!({"source": bad, "target": 1}), "source"), + ("appearance.transfer", json!({"source": 1, "target": bad}), "target"), + ("appearance.copyFrom", json!({"source": bad}), "source"), + ("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.info", json!({"group": bad}), "group"), + ("livePaint.info", id(), "id"), + ("select.magicWand", id(), "id"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + // `layer.move` checks an `id` that stands in for `ids` before it runs. + let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id")); + assert!(named, "{cmd} {p}: {e}"); + } + } + for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("missing"), "{cmd}: {e}"); + } + assert_eq!(undo(&s), before); +} + /// A command that takes the keys of one that isn't checked yet isn't checked either. #[test] fn a_reference_to_an_unchecked_command_is_unchecked() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index d21754e363..a1e615a08d 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1153,6 +1153,98 @@ fn deferred_stages_in_symbol_art_are_listed_and_refreshed() { assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([])); } +/// `effect.refreshDeferred` takes the id of an object in the art of a symbol, pattern, brush or +/// opacity mask, as `effect.deferredStatus` lists it: one with a deferred stage is refreshed, and +/// one without (or whose stage is gone) adds nothing to the counts. The id of art that is no +/// longer in the document is an error. +#[test] +fn refresh_takes_objects_in_definition_art() { + let mut s = session(); + const FX: &str = "plugin.test.engine.deferred.nested"; + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.nested", "effect", "{}"), &out)); + // A symbol whose art holds an object with the effect and one without. + let staged = rect(&mut s, 10.0, "#ff0000"); + let plain = rect(&mut s, 30.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"ids": [staged.0], "effect": FX})); + run(&mut s, "select.set", json!({"ids": [staged.0, plain.0]})); + run(&mut s, "symbol.new", json!({"name": "Nested"})); + // A pattern, an art brush (its source deleted) and an opacity mask, none with a stage. + let tile = rect(&mut s, 50.0, "#00ff00"); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Tile", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let bar = rect(&mut s, 70.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [bar.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Bar"})); + run(&mut s, "edit.clear", json!({"ids": [bar.0]})); + let masked = rect(&mut s, 90.0, "#ff0000"); + let mask = rect(&mut s, 90.0, "#ffffff"); + run(&mut s, "select.set", json!({"ids": [masked.0, mask.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + // A layer object with a stage that stays deferred, and one without. + let kept = rect(&mut s, 130.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"ids": [kept.0], "effect": FX})); + let bare = rect(&mut s, 150.0, "#ff0000"); + + // The ids of the objects of `art` that are not in the layers. + let nested = |s: &Session, art: &vectorcraft_doc::Node| { + let d = &s.doc().unwrap().doc; + let mut ids = Vec::new(); + vectorcraft_doc::swatches::walk_with_masks(art, &mut |n| { + if d.node(n.id).is_none() { + ids.push(n.id.0); + } + }); + ids + }; + let d = s.doc().unwrap().doc.clone(); + let in_symbol = nested(&s, &d.symbols[0].art); + let in_pattern = d.patterns.iter().flat_map(|p| p.art.iter().flat_map(|a| nested(&s, a))).collect::>(); + let brushes = vectorcraft_brush::library(&d); + let in_brush = brushes.iter().flat_map(|b| b.art().into_iter().flat_map(|a| nested(&s, a)).collect::>()).collect::>(); + let in_mask = d.node(masked).and_then(|n| n.extras().mask.as_ref()).map(|m| nested(&s, &m.art)).unwrap_or_default(); + for (what, ids) in [("symbol", &in_symbol), ("pattern", &in_pattern), ("brush", &in_brush), ("opacity mask", &in_mask)] { + assert!(!ids.is_empty(), "the {what} art has an object outside the layers"); + } + + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + let listed = |s: &mut Session| { + run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::>() + }; + let deferred = listed(&mut s); + let [nested_staged, last] = deferred[..] else { panic!("two stages: {deferred:?}") }; + assert!(in_symbol.contains(&nested_staged) && last == kept.0, "{deferred:?} {in_symbol:?}"); + let Some(&nested_plain) = in_symbol.iter().find(|i| **i != nested_staged) else { panic!("{in_symbol:?}") }; + + // Objects without a stage, each on its own and together: nothing to refresh. + let none = json!({"refreshed": 0, "stillDeferred": 0, "failed": 0}); + let mut plain_ids = vec![nested_plain, in_pattern[0], in_brush[0], in_mask[0], bare.0]; + for id in &plain_ids { + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": id})), none, "object {id}"); + } + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), none); + assert_eq!(listed(&mut s), [nested_staged, kept.0]); + + // The staged object of the symbol's art, by its id, with the others. + step.store(0, std::sync::atomic::Ordering::Relaxed); + plain_ids.push(nested_staged); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + assert_eq!(listed(&mut s), [kept.0]); + // Its stage is gone; the object is still there. + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": nested_staged})), none); + + // Art no longer in the document, and an id of no object. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.clear(); + assert!(s.execute("effect.refreshDeferred", &json!({"id": in_pattern[0]})).is_err(), "the pattern is gone"); + assert!(s.execute("effect.refreshDeferred", &json!({"ids": [nested_plain, 999_999]})).is_err(), "not an object of the document"); + assert_eq!(listed(&mut s), [kept.0]); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. @@ -1820,11 +1912,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 +1948,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 +1979,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 +2393,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..8248f12a48 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -483,6 +483,105 @@ fn the_folder_store_reads_no_linked_entries() { let _ = std::fs::remove_dir_all(&base); } +/// A file where a folder of the store's path should be: the error names that file, not the area +/// folder or the copy below it, for a write, a lock, a read, a listing, a removal and a recovery +/// save. +#[cfg(unix)] +#[test] +fn folder_errors_name_a_file_in_the_way() { + let base = std::env::temp_dir().join(format!("vc-recovery-file-in-way-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let file = base.join("file"); + std::fs::write(&file, b"").unwrap(); + let folder = file.join("recovery"); + let store = FolderStore::new(&folder); + let mut s = Session::new(); + s.prefs.recovery_folder = folder.to_string_lossy().to_string(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let errors = [ + ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err()), + ("lock", store.lock("1-2").err()), + ("read", store.read("1-1/Untitled-1-1.vectorcraft").err()), + ("list", store.list().err()), + ("remove", store.remove("1-1/Untitled-1-1.vectorcraft").err()), + ("save", s.execute("file.recovery.save", &json!({})).err().map(|e| e.to_string())), + ]; + let _ = std::fs::remove_dir_all(&base); + let named = format!("{} is not a folder: ", file.display()); + let wrong: Vec<_> = errors.iter().filter(|(_, e)| !e.as_deref().is_some_and(|e| e.contains(&named))).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// A removal that finds the copy missing because a file is on the way to it (how Windows reports a +/// file in the way) names that file. A copy that is just not there still counts as removed. +#[test] +fn a_removal_reported_missing_names_a_file_in_the_way() { + let base = std::env::temp_dir().join(format!("vc-recovery-remove-in-way-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let file = base.join("file"); + std::fs::write(&file, b"").unwrap(); + let missing = || std::io::Error::from(std::io::ErrorKind::NotFound); + let behind = FolderStore::removed(&file.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing())); + let gone = FolderStore::removed(&base.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing())); + let _ = std::fs::remove_dir_all(&base); + let named = format!("{} is not a folder: ", file.display()); + assert!(behind.as_ref().is_err_and(|e| e.starts_with(&named)), "{behind:?}"); + assert_eq!(gone, Ok(())); +} + +/// A folder of the store's path that is missing and can't be made: a write or a lock names it as +/// the folder it couldn't make, and a read names it as missing. An area folder that can't be made +/// in a store folder that is there is named the same way. +#[cfg(unix)] +#[test] +fn folder_errors_name_a_missing_folder_that_cannot_be_made() { + use std::os::unix::fs::PermissionsExt as _; + let base = std::env::temp_dir().join(format!("vc-recovery-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let closed = base.join("closed"); + std::fs::create_dir_all(&closed).unwrap(); + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o500)).unwrap(); + let gone = closed.join("gone"); + let store = FolderStore::new(gone.join("recovery")); + let unmade = format!("couldn't make {}: ", gone.display()); + let area = FolderStore::new(&closed).write("1-1/Untitled-1-1.vectorcraft", b"copy").err(); + let errors = [ + ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err(), unmade.clone()), + ("lock", store.lock("1-2").err(), unmade), + ("read", store.read("1-1/Untitled-1-1.vectorcraft").err(), format!("{} is missing", gone.display())), + ("area", area, format!("couldn't make {}: ", closed.join("1-1").display())), + ]; + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o700)).unwrap(); + let _ = std::fs::remove_dir_all(&base); + // An account that can write anywhere (root) makes the folders, so the test has nothing to check. + if errors[0].1.is_none() { + return; + } + let wrong: Vec<_> = errors.iter().filter(|(_, e, named)| !e.as_deref().is_some_and(|e| e.starts_with(named.as_str()))).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// When every folder on the way is there, an error names the folder it happened on. +#[cfg(unix)] +#[test] +fn folder_errors_name_the_folder_when_every_folder_is_there() { + use std::os::unix::fs::PermissionsExt as _; + let base = std::env::temp_dir().join(format!("vc-recovery-unreadable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let unreadable = base.join("unreadable"); + std::fs::create_dir_all(&unreadable).unwrap(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o300)).unwrap(); + let listed = FolderStore::new(&unreadable).list(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let _ = std::fs::remove_dir_all(&base); + // An account that can read any folder (root) lists it, so the test has nothing to check. + let Err(err) = listed else { return }; + assert!(err.starts_with(&format!("{}: ", unreadable.display())), "{err}"); +} + /// A store in memory that notes each removal of an area (its lock file and folder, then its empty /// folder) and whether the area was still held then. #[derive(Default)] @@ -682,6 +781,479 @@ 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 copy left in an area the session had to leave is not another app's to offer, restore or +/// discard while the session runs, even after the app that locked that area let it go: an entry in +/// the session's new area says it keeps it. Once the copy is written again in the new area, the +/// entry goes; after a crash before that, the copy is offered. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_is_not_offered_by_another_app_while_its_app_runs() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftother-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let left_behind = |area: &str| dir.join(area).join("Untitled-1-1.vectorcraft"); + let leave = |a: &mut Session, area: &str| { + std::fs::remove_file(dir.join(area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(a); + drop(other); + a.recovery.own_area().unwrap().to_string() + }; + let (mut a, area) = saved_in(&dir); + let mine = leave(&mut a, &area); + assert_ne!(mine, area); + assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file()); + // Removed meanwhile: the next heartbeat writes it again. + std::fs::remove_file(dir.join(&mine).join(format!("keeps-{area}"))).unwrap(); + cmd::recovery::heartbeat(&mut a); + assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file()); + let mut b = other_app(&dir); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(b.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err()); + assert!(left_behind(&area).is_file()); + // Written again in the new area: the copy left behind and the entry go. + a.execute("file.recovery.save", &json!({})).unwrap(); + assert!(!left_behind(&area).exists()); + assert!(!dir.join(&mine).join(format!("keeps-{area}")).exists()); + assert!(offered(&mut b).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); + // A crash before the copy is written again: the copy left behind is offered. + let (mut a, area) = saved_in(&dir); + leave(&mut a, &area); + drop(a); + assert_eq!(offered(&mut other_app(&dir)).len(), 1); + assert!(left_behind(&area).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A store in memory whose areas named `foreign-…` can't be locked by this account (as another +/// account's folder in a shared recovery folder). +#[derive(Default)] +struct ForeignAreas(MemoryStore); + +impl RecoveryStore for ForeignAreas { + fn list(&self) -> std::result::Result, String> { + self.0.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.0.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.0.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.0.remove(name) + } + fn location(&self) -> String { + self.0.location() + } + fn lock(&self, area: &str) -> std::result::Result { + if area.starts_with("foreign-") { + return Err(format!("{area}: belongs to another account")); + } + self.0.lock(area) + } + fn now(&self) -> Option { + self.0.now() + } +} + +/// An entry `/keeps-` in an area this account can't lock keeps nothing: a crashed +/// app's copies in the left area are still offered. +#[test] +fn a_keeps_entry_in_an_area_that_cannot_be_locked_keeps_nothing() { + let store = Arc::new(ForeignAreas::default()); + let app = || { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s + }; + let mut a = app(); + a.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + drop(a); + store.write(&format!("foreign-1/keeps-{area}"), b"").unwrap(); + let mut b = app(); + assert_eq!(offered(&mut b).len(), 1); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1); +} + +/// An entry `/keeps-` in a running app's area keeps a crashed app's copies in the +/// left area until that app is gone too. +#[test] +fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { + let store = Arc::new(MemoryStore::default()); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + drop(a); + let mut c = session_in(&store); + c.execute("file.recovery.save", &json!({})).unwrap(); + let keeper = c.recovery.own_area().unwrap().to_string(); + store.write(&format!("{keeper}/keeps-{area}"), b"").unwrap(); + let mut b = relaunch(&store); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + drop(c); + assert_eq!(offered(&mut b).len(), 2); +} + +/// An entry `/keeps-` in a folder whose lock file can't be opened keeps nothing. +#[cfg(unix)] +#[test] +fn a_keeps_entry_in_a_folder_whose_lock_cannot_be_checked_keeps_nothing() { + let dir = std::env::temp_dir().join(format!("vc-recovery-keepsunlocked-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (a, area) = saved_in(&dir); + drop(a); + std::fs::create_dir_all(dir.join("keeper").join(".lock")).unwrap(); + std::fs::write(dir.join("keeper").join(format!("keeps-{area}")), b"").unwrap(); + assert_eq!(offered(&mut other_app(&dir)).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A complex document (Data Recovery off for those) gets no new copy after the session left its +/// area: it keeps the copy it left there, and the heartbeat asks for it only once. +#[cfg(unix)] +#[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..9932055652 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize { s.doc().unwrap().doc.node_count() } +/// A client confined by automation roots: a save without a path writes the document's own file, and +/// when that file is outside the read roots the reply leaves out its `path` and names the files +/// written beside it by their file names. A reply without a path leaves out a suggested `folder` +/// outside them. A path inside the read roots, and one the client gives, stay whole. +#[test] +fn a_confined_save_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots"); + let (inside, outside) = (d.join("in"), d.join("out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap(); + let files: Vec = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}"); + + let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap(); + let run = |s: &mut Session, roots: &Option>, id: &str, p: Value| { + confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}")) + }; + let hidden = outside.to_string_lossy().into_owned(); + let r = run(&mut s, &write_only, "document.save", json!({})); + assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true})); + let names: Vec = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect(); + assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}"); + // Inside the read roots, or given by the client: whole. + assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own)); + assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own)); + // A save without a path known suggests the document's folder. + let r = run(&mut s, &write_only, "file.saveCopy", json!({})); + assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}"); + assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden)); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); @@ -425,8 +469,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 +492,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 +510,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 +682,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_engines.rs b/crates/engine/src/tests/script_engines.rs index 5506cb98d1..3171657b32 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for}; +use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, TEST_KEPT_MAP_BYTES, engines_on_this_thread, releases_kept_for}; use crate::*; fn run(s: &mut Session, source: &str) -> Result { @@ -119,11 +119,115 @@ fn only_the_script_itself_names_its_engine() { assert_eq!(report["line"], 2, "{report}"); assert_eq!(names(&mut s), ["e"]); let _ = std::fs::remove_dir_all(base); - // An error in a function an earlier run defined has no place in the run that calls it. + // An error in a function an earlier run defined is at that run's line; a script given as text + // is named by its run's name. run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap(); - assert!(run(&mut s, "#targetengine e\nbad();").is_err()); + let e = run(&mut s, "#targetengine e\nbad();").unwrap_err().to_string(); let report = s.script_report().unwrap().clone(); - assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}"); + assert_eq!((report["name"].as_str(), report["file"].as_str(), report["line"].as_u64()), (Some("t.js"), Some("t.js"), Some(5)), "{report}"); + let column = report["column"].as_u64().unwrap(); + assert_eq!(error_lines(&report), [format!("t.js: t.js:5:{column}: Error: kept")]); + assert!(e.contains(&format!("line 5, column {column} of t.js")), "{e}"); +} + +/// The console error lines of a run's report. +fn error_lines(report: &Value) -> Vec { + let lines = report["console"]["lines"].as_array().unwrap(); + lines.iter().filter_map(Value::as_str).filter(|l| l.contains("Error: ")).map(str::to_string).collect() +} + +/// The file, line and column of the last run's error. +fn place(s: &Session) -> (Option, u64, u64) { + let r = s.script_report().unwrap(); + (r["file"].as_str().map(str::to_string), r["line"].as_u64().unwrap(), r["column"].as_u64().unwrap()) +} + +/// An error in code an earlier run of a named engine compiled is at the place of that code: in that +/// run's script, a file it included or a file `$.evalFile` gave, while the report and the console +/// line keep the calling run's name. +#[test] +fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-places-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("inc.jsxinc"), "\nfunction fromInclude() { throw new Error('inc'); }").unwrap(); + std::fs::write(base.join("ev.jsxinc"), "\n\n\nfunction fromEval() { throw new Error('ev'); }").unwrap(); + let def = base.join("def.jsx"); + let text = "#targetengine e\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');\nfunction fromMain() {\n throw new Error('main');\n}\n\ + function spin() {\n while (true) {}\n}\nfunction badEval() {\n eval('var = 1');\n}"; + std::fs::write(&def, text).unwrap(); + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + let call = |s: &mut Session, code: &str| { + let e = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"})); + assert!(e.is_err(), "{code}"); + }; + let shown = def.to_string_lossy().to_string(); + let ends = |p: &(Option, u64, u64), file: &str, line: u64| p.0.as_deref().is_some_and(|f| f.ends_with(file)) && p.1 == line && p.2 > 0; + call(&mut s, "fromMain();"); + let p = place(&s); + assert!(ends(&p, "def.jsx", 5), "{p:?}"); + let report = s.script_report().unwrap().clone(); + assert_eq!(report["name"], "caller.js"); + assert_eq!(error_lines(&report), [format!("caller.js: {}:5:{}: Error: main", p.0.as_deref().unwrap(), p.2)]); + call(&mut s, "fromInclude();"); + assert!(ends(&place(&s), "inc.jsxinc", 2), "{:?}", place(&s)); + call(&mut s, "fromEval();"); + assert!(ends(&place(&s), "ev.jsxinc", 4), "{:?}", place(&s)); + call(&mut s, "badEval();"); + assert!(ends(&place(&s), "def.jsx", 11), "a syntax error eval raises is at its call: {:?}", place(&s)); + let e = s.execute("script.run", &json!({"source": "#targetengine e\nspin();", "name": "caller.js", "maxFuel": 100_000})); + assert!(e.is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "limit"); + assert!(ends(&place(&s), "def.jsx", 8), "the fuel ran out in spin: {:?}", place(&s)); + // A caught error thrown again is where that throw is, in the calling run. + call(&mut s, "try { fromMain(); } catch (x) {\n throw x;\n}"); + assert_eq!(place(&s), (None, 4, 3)); + // A run that fails before it starts compiles nothing, and the kept places stay. + assert!(run(&mut s, "#targetengine e\n#include \"missing.jsxinc\"").is_err()); + call(&mut s, "fromMain();"); + assert!(ends(&place(&s), "def.jsx", 5), "{:?}", place(&s)); + assert!(place(&s).0.is_some_and(|f| f == shown)); + // A dropped engine takes its places with it: the new one has its own. + s.execute("script.engine.drop", &json!({"name": "e"})).unwrap(); + run(&mut s, "#targetengine e\nfunction fromMain() { throw new Error('new'); }").unwrap(); + call(&mut s, "fromMain();"); + assert_eq!(place(&s).0.as_deref(), Some("t.js")); + assert_eq!(place(&s).1, 2); + // The name stands for the earlier script as that run's report gave it, not as console lines + // show it. + s.execute("script.run", &json!({"source": "#targetengine e\nfunction named() { throw 1; }", "name": "a\u{1}b.js"})).unwrap(); + call(&mut s, "named();"); + assert_eq!(place(&s).0.as_deref(), Some("a\u{1}b.js")); + let _ = std::fs::remove_dir_all(base); +} + +/// A named engine keeps the places of its earlier runs' code within its budget, the oldest going +/// first, and the code still runs once its place is gone. +#[test] +fn an_engine_keeps_the_newest_places_within_its_budget() { + let mut s = Session::new(); + run(&mut s, "#targetengine e\nfunction a() { throw new Error('a'); }").unwrap(); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300))); + run(&mut s, "#targetengine e\n\nfunction b() { throw new Error('b'); }").unwrap(); + for _ in 0..20 { + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + } + run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap(); + // Each run's own map takes the room of the one before, so c is called first. + assert!(run(&mut s, "#targetengine e\nc();").is_err()); + let c_place = place(&s); + let a = run(&mut s, "#targetengine e\na();").map(drop); + let a_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1))); + run(&mut s, "#targetengine e\n\n\n\nfunction d() { throw new Error('d'); }").unwrap(); + assert!(run(&mut s, "#targetengine e\nd();").is_err()); + let d_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + assert!(a.is_err()); + assert_eq!((a_place.0, a_place.1, a_place.2), (None, 0, 0), "a's place went to make room"); + assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4)); + assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } /// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to diff --git a/crates/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/stylelib.rs b/crates/engine/src/tests/stylelib.rs index d1d4155904..fd8f2c7e24 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -87,6 +87,87 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); } +#[test] +fn painted_and_sketched_styles_set_every_effect_setting_and_reopen_applied() { + let mut s = session(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + let at = libs.iter().position(|l| l["id"] == "painted-sketched").expect("listed"); + assert_eq!((libs[at]["name"].as_str(), libs[at]["count"].as_u64()), (Some("Painted and Sketched"), Some(7))); + assert_eq!(libs[at - 1]["id"], "textures"); + let g = run(&mut s, "graphicStyle.library", json!({"library": "Painted and Sketched"})); + let listed: Vec<(&str, &str, Vec<&str>)> = + g["styles"].as_array().unwrap().iter().map(|x| (x["name"].as_str().unwrap(), x["fill"].as_str().unwrap(), names(&x["effects"]))).collect(); + let want = [ + ("Washed Blue", "#8ab9c9", "artistic.watercolor"), + ("Bristle Ochre", "#c4975a", "artistic.dryBrush"), + ("Pastel Heather", "#a78ca5", "artistic.roughPastels"), + ("Charcoal Shade", "#666666", "sketch.charcoal"), + ("Ink Contour", "#d9e2df", "brushStrokes.inkOutlines"), + ("Wet Ink", "#637b85", "brushStrokes.sumiE"), + ("Fine Pen", "#d8d4c6", "sketch.graphicPen"), + ]; + assert_eq!(listed, want.iter().map(|&(n, f, e)| (n, f, vec![e])).collect::>()); + // Every setting of each effect is given, with the catalog's key and kind of value. + let (_, lib) = cmd::stylelib::library(&s, "painted-sketched").unwrap(); + for st in &lib.styles { + let e = &st.appearance.effects[0]; + let defaults = vectorcraft_render::effects::effect_info(&e.id).unwrap().defaults; + let (set, all) = (e.params.as_object().unwrap(), defaults.as_object().unwrap()); + let keys = |m: &serde_json::Map| m.keys().cloned().collect::>(); + assert_eq!(keys(set), keys(all), "{}", st.name); + for (k, v) in set { + let kind = |v: &Value| (v.is_number(), v.is_string(), v.is_boolean()); + assert_eq!(kind(v), kind(&all[k]), "{} / {k}", st.name); + } + } + let strokes: Vec<(&str, f64)> = + lib.styles.iter().filter(|g| g.appearance.stroke_paint() != Paint::None).map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect(); + assert_eq!(strokes, [("Bristle Ochre", 1.0), ("Ink Contour", 1.0)]); + + // Each one adds and applies to a rectangle in one undo step. + let mut ids = vec![]; + for (i, (name, fill, effect)) in want.iter().enumerate() { + let id = rect(&mut s, 10.0 + 25.0 * i as f64, 20.0, 20.0, 30.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name))); + assert_eq!(undo_len(&s), before + 1, "{name}"); + let node = doc(&s).node(id).unwrap(); + assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex(fill).unwrap()), "{name}"); + assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), [*effect]); + assert_eq!(node.appearance.effects[0].params, lib.style(name).unwrap().appearance.effects[0].params, "{name}"); + ids.push((id, *name)); + } + // Adding one again reuses it: no new style, no undo step. + let before = (undo_len(&s), doc(&s).graphic_styles.len()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink"})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Wet Ink"])); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + // An unknown style or library changes nothing. + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-sketched", "names": ["Wet Ink", "Nope"]})).is_err()); + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-and-sketched", "name": "Wet Ink"})).is_err()); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + + // Saved and opened again, the styles keep their effects and the rectangles stay linked to them. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "painted.vectorcraft", "dataBase64": b64})); + for (id, name) in &ids { + let d = doc(&s); + let style = d.graphic_style(name).unwrap(); + assert!(style.same_look(lib.style(name).unwrap()), "{name}"); + let node = d.node(*id).unwrap(); + assert_eq!((node.extras().graphic_style, &node.appearance.effects), (Some(style.id), &style.appearance.effects), "{name}"); + } + + // With nothing selected, `apply` only adds; empty `names` adds the whole library in order. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Fine Pen", "apply": true})); + assert!(r.get("applied").is_none() && doc(&s).graphic_style("Fine Pen").is_some()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "names": []})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (want[..6].iter().map(|w| w.0).collect::>(), vec!["Fine Pen"])); +} + #[test] fn add_dedupes_names_as_one_undo_step() { let mut s = session(); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 3ecb5e68e1..695a2962a5 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -638,6 +638,77 @@ 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 holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() { + let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap(); + let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") }; + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode})); + let list = run(&mut s, "swatch.library.list", json!({})); + let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone(); + assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14))); + let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"})); + assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo"); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + let before = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}"); + assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step"); + // The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method. + let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") }; + assert_eq!(added.gradient, want.gradient, "{mode}"); + assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB"); + assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge"); + let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") }; + assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}"); + assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}"); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add"); + assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill"); + // A document swatch already named Mint Halo stays; the glow comes in under a number. + run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"})); + let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]})); + assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}"); + assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept"); + assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient)); + } +} + #[test] fn pattern_libraries_bring_their_patterns_and_add_each_once() { let mut s = session(); @@ -765,6 +836,39 @@ fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() { assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint)); } +/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch +/// once, as one undo step that also takes both away, and both stay after a save and reopen. +#[test] +fn a_grit_texture_adds_once_undoes_and_saves() { + let mut s = session(); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]})); + assert_eq!(r["added"], json!(["Stipple"])); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone(); + assert!(def.art.len() >= 144, "{} marks", def.art.len()); + assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple")); + let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none()); + run(&mut s, "edit.redo", json!({})); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Stipple"), Some(&def)); + assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9eea9cf456..abb39c0d96 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"), ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), + ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), @@ -2523,6 +2524,68 @@ fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { } } +/// Horizontal and vertical type keep every line where it is when converted from point to area +/// type and back, and from area to point type and back, however the lines are aligned. Vertical +/// lines run down their columns, and each next column sits to the left of the one before it. +#[test] +fn converting_vertical_type_between_point_and_area_keeps_it_in_place() { + for vertical in [false, true] { + for j in ["left", "center", "right"] { + for text in ["Wave", "Wave\nWaves"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 300, "y": 40, "text": text, "size": 12, "vertical": vertical})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), text.lines().count()); + if vertical && before.len() > 1 { + assert!(before[1].x < before[0].x, "{j}: the second column is to the left: {before:?}"); + } + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + let area = line_starts_doc(&t); + assert_eq!(area.len(), before.len(), "vertical {vertical}, {j}, {text:?}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + let point = line_starts_doc(&t); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type to point type and back. + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 300, "y": 40, "text": "Wave\nWaves", "size": 12, "vertical": vertical, "area": {"width": 120, "height": 120}}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, point: {before:?} → {point:?}"); + } + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, area again: {before:?} → {area:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session(); 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/src/tooling.rs b/crates/engine/src/tooling.rs index b222f32107..9bc450f937 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -751,6 +751,27 @@ impl Session { self.apply_actions(acts).map(drop) } + /// The rulers' origin dragged out of their corner, whatever the tool, with the pointer at `p` in + /// document space (none off the canvas): where it goes, snapped as a drawn point is + /// ([`vectorcraft_tools::rulerguide::OriginDrag`]), the targets gathered on the drag's first + /// call. It changes nothing: the caller sets the origin where the button is released + /// (`document.setRulerOrigin`) and ends the drag ([`Session::end_ruler_origin_drag`]). + pub fn ruler_origin_drag(&mut self, p: Option, view: ViewInfo) -> Option { + let drag = match self.ruler_origin.take() { + Some(d) => Some(d), + None => self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::OriginDrag::new(cx))), + }; + let Some(mut drag) = drag else { return p }; + let at = self.with_tool_cx(view, |_, cx| drag.pointer(cx, p)); + self.ruler_origin = Some(drag); + at + } + + /// The drag of the rulers' origin ended, released or cancelled: its guides go. + pub fn end_ruler_origin_drag(&mut self) { + self.ruler_origin = None; + } + /// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the /// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event. pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result> { @@ -1112,6 +1133,9 @@ impl Session { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); } + if let Some(d) = &self.ruler_origin { + v.extend(d.overlays()); + } let deferred_preview = self.unapplied_interaction_preview(); if let Some(d) = self.active() { let w = self.prefs.perspective_widget; diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 002f4df388..ce7fc2e33a 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/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index ba029dd3b1..db8696a10f 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -115,15 +115,27 @@ fn both_lexers_take_the_same_characters_into_names() { assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}")); for &c in NAME_CHARS { let escape = format!("\\u{:04X}", u32::from(c)); + let braced = format!("\\u{{{:X}}}", u32::from(c)); for (src, name) in [ (format!("a{c}b"), format!("a{c}b")), (format!("{c}b"), format!("{c}b")), (format!("a{escape}b"), format!("a{c}b")), (format!("{escape}b"), format!("{c}b")), + (format!("a{braced}b"), format!("a{c}b")), + (format!("{braced}b"), format!("{c}b")), ] { assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); } } + // Braced escapes past U+FFFF and with leading zeros are read by both. + for (src, name) in [("\\u{1D49C}b", "\u{1D49C}b"), ("a\\u{0000000062}", "ab")] { + assert!(checker_member(src, name) && one_identifier(src, name), "{src:?}"); + } + // Out of range, a surrogate and an empty one: the parser reads no identifier, and the checker's + // name ends before the escape. + for src in ["a\\u{110000}", "a\\u{D800}", "a\\u{}"] { + assert!(checker_member(src, "a") && !one_identifier(src, "a"), "{src:?}"); + } } #[test] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index f2ba516b46..4939327c9d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3785,6 +3785,34 @@ fn an_unstroked_run_with_a_stroke_style_loses_nothing() { } } +/// The text document has no Korean Line Break Anywhere: a paragraph with it on and Hangul in it +/// is reported as left out, and comes back off; with it off, or with no Hangul, nothing is +/// reported. +#[test] +fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [("가나다 라마", true, true), ("가나다 라마", false, false), ("Plain", true, false)] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.korean_line_break_anywhere = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).korean_line_break_anywhere), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); @@ -4366,6 +4394,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() { assert!(!texts[0].dynamic_text); let k = texts[0].xf.as_coeffs()[0]; assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn)); + // Type written as outlines (here on an open path whose ends meet) loses the setting too, and + // says so. + let mut doc = Document::new(600.0, 300.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK)); + let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()]; + t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let node = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings); } /// See-through type with the knockout shape flag comes back with the flag and writes no warning. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index afa6412561..dea4ad0c24 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> { return; } self.warn(TYPE); + // The outlines keep the sizes Dynamic Text draws the type at, but not the setting. + if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) { + self.warn(DYNAMIC_TEXT); + } let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 5ebd73de3a..1d791afadf 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -385,6 +385,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { if p.justify == Justify::Auto && p.direction.is_none() && rtl_letter { return true; } + let hangul = + text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -398,6 +400,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // Width, Standard burasagari) isn't reported: it changes no other script's lines. mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi }, burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari }, + // The text document has no Korean Line Break Anywhere: it reads back off, which + // changes only the lines of a paragraph with Hangul in it. + korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, ..ParaStyle::default() }; *p != held diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index e256e305c2..5e058acaf6 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -97,6 +97,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 43b28c934a..b9ffcd0305 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1292,6 +1292,74 @@ fn a_font_height_reference_is_written_as_the_size_drawn() { assert!((size - 20.0).abs() < 1e-6, "{size}"); } +/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that +/// the setting itself is not written; without Dynamic Text there is no warning. +#[test] +fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() { + let mut doc = typed(); + assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT)); + let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id; + let mut drawn = 0.0; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.size = 4.0; + t.dynamic_text = true; + drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale; + } + assert!(drawn > 4.0, "{drawn}"); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let imported = crate::import(&out.bytes).unwrap(); + let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap(); + assert!(!back.dynamic_text); + assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size); + // Type written as outlines loses the setting too, and says so. + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].text.push('\u{2192}'); + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); +} + +/// Dynamic Text fits type whose size measures the cap height at the size the Character panel +/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is +/// written at the em size drawn there, as text and as outlines. +#[test] +fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() { + use vectorcraft_doc::{TextKind, TextObject}; + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(height_font(true).unwrap()); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red())); + let st = &mut t.runs[0].style; + st.font_family = HEIGHT_FAMILY.into(); + st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + let mut line = PathData::new(vec![]); + line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false)); + t.kind = TextKind::OnPath { path: line, start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let lay = vectorcraft_text::layout(db, &t); + assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale); + let em = 1296.0 / 0.7; + let mut glyphs = kurbo::BezPath::new(); + for g in &lay.glyphs { + glyphs.extend(g.outline.iter()); + } + let want = glyphs.bounding_box(); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let back = crate::import(&write(&doc).bytes).unwrap().document; + let size = texts(&back)[0].1.runs[0].style.size; + assert!((size - em).abs() < 1e-3, "{size} vs {em}"); + let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document; + let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap(); + assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}"); +} + /// Characters with Auto leading are written with their paragraph's Auto Leading share of the /// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 8f8fc4ccf2..3373330892 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -3,8 +3,8 @@ use super::encoding::{ unit, writable, }; use super::{ - Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, - GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, + Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, @@ -733,15 +733,15 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type: a text object when the format can hold it as text, else its outlines (type with - /// rotated characters among them, which keeps their rotation). A run whose - /// font size measures a height other than the em box is written at the em size it is drawn - /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]). + /// rotated characters among them, which keeps their rotation). fn text(&mut self, t: &TextObject) { if !invertible(t.xf) { return; } - let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); - let t = &*resolved; + // Written as text or as outlines, the type keeps its drawn sizes but not the setting. + if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) { + self.warn(DYNAMIC_TEXT); + } if !self.outline_text { if writable(self.doc, t) { return self.text_object(t); @@ -759,14 +759,18 @@ impl<'a> Writer<'a> { /// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame /// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix /// takes text space (y up, the first baseline's start for point type) to art space; the frame - /// and path are in art space, as the specification's examples have them (§15.5). + /// and path are in art space, as the specification's examples have them (§15.5). Type that + /// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at + /// the sizes the Character panel shows; then a run whose font size measures a height other + /// than the em box is written at the em size it is drawn at + /// ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text_object(&mut self, t: &TextObject) { if lost_attributes(t) { self.warn(TYPE_ATTRIBUTES); } - // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in. let fitted = vectorcraft_text::drawn(t); - let t = &*fitted; + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted); + let t = &*resolved; let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let (kind, path, start) = match &t.kind { TextKind::Point => (0, None, 0.0), 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 JsResult<()> { @@ -278,17 +279,20 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int if len == 0 { return Ok(Value::str("")); } - let flat = sep.flat(); - let sep = &flat[..]; - // A result that cannot fit is refused before any element is read. - let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); - if least > (interp.limits.heap_bytes / 4) as u64 { - return Err(Abrupt::Limit(crate::LimitKind::Heap)); - } - interp.join_stack.push(o); - let r = join_elements(interp, o, len, sep, item); - interp.join_stack.pop(); - r + // The separator stays rooted for the whole join, so its flattened copy stays counted. + interp.rooted(&[Value::String(sep.clone())], |interp| { + let flat = flat_charged(interp, &sep)?; + let sep = &flat[..]; + // A result that cannot fit is refused before any element is read. + let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); + if least > (interp.limits.heap_bytes / 4) as u64 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.join_stack.push(o); + let r = join_elements(interp, o, len, sep, item); + interp.join_stack.pop(); + r + }) } fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { @@ -301,7 +305,10 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( let Some(s) = item(interp, v)? else { continue }; out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; - out.push_units(interp, &s.flat())?; + interp.rooted(&[Value::String(s.clone())], |interp| { + let units = flat_charged(interp, &s)?; + out.push_units(interp, &units) + })?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish(interp)) @@ -472,7 +479,7 @@ fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul with_this(interp, &this, |interp, o| { let len = u64::from(length_of(interp, o)?); let count = args.len() as u64; - // With no arguments each element is still read and written back in place (steps 6 and 7). + // With no arguments each element is still read and written back in place (step 6). let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; while let Some(k) = it.next(interp)? { diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 4375303fe0..364ec99696 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -473,7 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult s.gap = t.flat().iter().take(10).copied().collect(), + Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), _ => {} } Ok(s) @@ -632,7 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), Value::Number(_) => out.push_str(interp, "null"), - Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::String(s) => interp.rooted(std::slice::from_ref(v), |i| { + let units = flat_charged(i, s)?; + write_quoted(i, out, &units) + }), Value::Undefined | Value::Object(_) => Ok(()), } } diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 3092530d46..6992fd5606 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -160,8 +160,11 @@ pub(crate) enum Op { parts: u32, list: bool, }, - /// `name` → the value of the name on the nearest XML value in scope. + /// `name` → the value of the name on the nearest XML value in scope that holds it. XmlScopeGet, + /// `name` → `object name`: the nearest XML value in scope that holds the name, for an + /// assignment, update or `delete`. + XmlScopeRef, /// `object` → the filter's state. XmlFilterStart, /// `state` → `state item`, or jumps with `state` when the items are used up. @@ -170,8 +173,9 @@ pub(crate) enum Op { XmlFilterTest, /// `state` → the XMLList of the items kept. XmlFilterEnd, - /// `iterator name` → `iterator value`: the value under the name a `for each` got. - ForEachValue, + /// `iterator name x₁…xₙ` → `iterator name x₁…xₙ value`: the value under the name a `for each` + /// got, read after the `n` values its target pushed. + ForEachValue(u32), /// `value` → nothing: sets the default XML namespace of the running code. SetDefaultNamespace, } @@ -308,7 +312,8 @@ fn effect(op: Op, finally_tables: &[Vec]) -> 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..fbb5e4fc90 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -395,13 +395,31 @@ impl Interp { Err(e) => return Err(self.host_error(e)?), }; let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); - self.evaluated = self.evaluated.saturating_add(1); + self.evaluated = crate::realm::next_tag(self.evaluated); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); - 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..de252c6c64 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -638,6 +638,76 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn error_tags_stay_the_same_from_run_to_run_and_are_never_shared() { + let mut r = realm(10_000_000); + let source: HostFunction = Rc::new(|_| Ok(Data::String("function inEval() { throw new Error('in eval'); }".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("load".into(), load)]).unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "no script yet"); + r.run("function late() { throw new Error('late'); }\nhost.load(); host.load();").unwrap(); + let first = r.script_tag(); + assert_ne!(first, UNKNOWN_SOURCE); + assert!(r.run("\nlate()").is_err()); + let second = r.script_tag(); + assert!(second > first + 2, "the evaluator's two sources took the tags after the first script"); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, first), "late keeps its script's tag"); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), first + 2, "the second source the evaluator gave in the first run"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (0, r.script_tag())); + // Once the tags are used up, a source gets no tag rather than one an earlier source has. + r.set_last_tag(UNKNOWN_SOURCE - 3); + r.run("function nearEnd() { throw 1; }\nhost.load(); host.load(); host.load();").unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("nearEnd()").is_err()); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "a script after the last tag"); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE, "the third source came after the last tag"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, UNKNOWN_SOURCE)); +} + +#[test] +fn an_evaluators_source_takes_fuel_before_it_is_parsed() { + // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. + 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..22a365b615 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 @@ -26,6 +27,7 @@ use crate::heap::{ ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes, }; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; use crate::{LimitKind, Limits, Options}; @@ -204,6 +206,12 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc, + /// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String + /// object, which the heap's total adds before the next limit check. + flattened: std::cell::Cell, + /// Fuel for the units those copies took and the rope nodes it read in place, which the next + /// step takes. + pub(crate) flattened_fuel: std::cell::Cell, /// The global code's default XML namespace URI (ECMA-357 section 12.1). pub(crate) xml_default_uri: Key, /// Set by the host to end the current slice of a resumable run at the next safe point. @@ -449,6 +457,8 @@ impl Interp { files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + flattened: std::cell::Cell::new(0), + flattened_fuel: std::cell::Cell::new(0), xml_default_uri: Rc::from(Vec::new()), pause: Rc::default(), in_slice: false, @@ -568,6 +578,7 @@ impl Interp { fn tick(&mut self) -> JsResult<()> { self.use_fuel()?; + crate::regexp_vm::use_fuel_n(self, self.flattened_fuel.replace(0))?; self.settle_held(); if self.heap.bytes > self.gc_threshold { self.collect_garbage(); @@ -594,10 +605,12 @@ impl Interp { Ok(()) } - /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total. + /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total, and adds the + /// bytes of the ropes [`Self::get_own_property`] flattened. fn settle_held(&mut self) { let released = self.held.released.replace(0); - self.heap.bytes = self.heap.bytes.saturating_sub(released); + let flattened = self.flattened.replace(0); + self.heap.bytes = self.heap.bytes.saturating_sub(released).saturating_add(flattened); } /// Collects garbage now and sets when the next collection runs. @@ -617,6 +630,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) { @@ -663,6 +692,7 @@ impl Interp { self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. self.held.released.set(0); + self.flattened.set(0); self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get()); } @@ -926,11 +956,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"), } @@ -1217,7 +1248,33 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + let unit = if s.is_rope() { + let owed = self.flattened_fuel.get(); + let flattened = self.flattened.get().saturating_add(string_bytes(s.len())); + if self.heap.bytes.saturating_add(flattened) > self.limits.heap_bytes { + // The copy does not fit without a collection, which needs `&mut self`: + // the unit is read along the rope instead, a unit of fuel per node. + let Some((unit, visited)) = s.unit_at(i as usize, self.fuel.saturating_sub(owed)) else { + return Err(Abrupt::Limit(LimitKind::Fuel)); + }; + self.flattened_fuel.set(owed.saturating_add(visited)); + unit + } else { + // Flattening copies the units. The run ends here when the fuel for + // the copy is not left; otherwise the next step takes it, and the + // next limit check adds the copy's bytes. + let fuel = owed.saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); + if fuel > self.fuel { + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.flattened_fuel.set(fuel); + self.flattened.set(flattened); + s.flat().get(i as usize).copied() + } + } else { + s.flat().get(i as usize).copied() + } + .unwrap_or(0); return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE })); } } @@ -1294,7 +1351,7 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + let unit = flat_charged(self, s)?.get(i as usize).copied().unwrap_or(0); return Ok(Value::String(Str::from_units(vec![unit]))); } } @@ -2401,7 +2458,8 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; - let units = s.flat(); + self.prepare_source(s.len())?; + let units = self.flat_reserved(s)?; let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let frame = self.frame()?; @@ -2566,7 +2624,7 @@ impl Interp { Value::Null => 0.0, Value::Bool(b) => f64::from(u8::from(*b)), Value::Number(n) => *n, - Value::String(s) => string_to_number(&s.flat()), + Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; return self.to_number(&p); @@ -2600,19 +2658,28 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_key(&mut self, v: &Value) -> JsResult { match v { - Value::String(s) => { - let rope = s.is_rope(); - let flat = s.flat(); - if rope { - self.heap.bytes += string_bytes(flat.len()); - } - Ok(flat) - } + Value::String(s) => self.flat_reserved(s), Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), - _ => Ok(self.to_str(v)?.flat()), + _ => { + let s = self.to_str(v)?; + self.flat_reserved(&s) + } } } + /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that + /// copy's bytes are reserved and added to the heap first, without taking fuel. The rope stays + /// rooted while the copy is reserved; everything else the caller still needs must be + /// reachable from the roots. + fn flat_reserved(&mut self, s: &Str) -> JsResult { + if s.is_rope() { + let bytes = string_bytes(s.len()); + self.rooted(&[Value::String(s.clone())], |i| i.reserve_heap(bytes))?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + } + Ok(s.flat()) + } + /// ToObject (section 9.9). #[allow(clippy::wrong_self_convention)] pub(crate) fn to_object(&mut self, v: &Value) -> JsResult { @@ -2735,7 +2802,11 @@ impl Interp { (self.to_primitive(x, Hint::Number)?, py) }; if let (Value::String(a), Value::String(b)) = (&px, &py) { - return Ok(Some(*a.flat() < *b.flat())); + // Both stay rooted, so the first copy is still counted while the second is made. + return self.rooted(&[px.clone(), py.clone()], |i| { + let (a, b) = (flat_charged(i, a)?, flat_charged(i, b)?); + Ok(Some(*a < *b)) + }); } let nx = self.to_number(&px)?; let ny = self.to_number(&py)?; @@ -2760,7 +2831,9 @@ impl Interp { | (Value::String(_), Value::String(_)) | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), - (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())), + (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { + return Ok(*n == string_to_number(&flat_charged(self, s)?)); + } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), (Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?, diff --git a/crates/js/src/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/lib.rs b/crates/js/src/lib.rs index 7df9953a67..cad290f1af 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -41,9 +41,11 @@ //! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): //! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and //! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, -//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! `isXMLName` and `setDefaultXMLNamespace`, and XML and XMLList values have an `xpath` method that +//! evaluates an XPath 1.0 expression. Without the option, the language and the built-ins are //! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and -//! serializing them use no native recursion and take fuel for each node. +//! serializing them, and evaluating XPath on them, use no native recursion and take fuel for each +//! node. #![forbid(unsafe_code)] pub mod ast; @@ -70,6 +72,7 @@ mod token; mod value; mod xml; mod xml_builtins; +mod xpath; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, diff --git a/crates/js/src/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..077f3b19b6 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 @@ -62,6 +63,12 @@ pub enum LimitKind { /// evaluator's: code that `eval` or `Function` compiled, or an error with no place. pub const UNKNOWN_SOURCE: u32 = u32::MAX; +/// The source tag after `tag`. Once the tags are used up, every later source gets +/// [`UNKNOWN_SOURCE`], so no two sources share a tag. +pub(crate) fn next_tag(tag: u32) -> u32 { + if tag >= UNKNOWN_SOURCE - 1 { UNKNOWN_SOURCE } else { tag + 1 } +} + /// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run /// it cannot pause. pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; @@ -150,7 +157,9 @@ pub struct Realm { unbroken_fuel: u64, /// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each /// script gets a new one, and the sources an evaluator gives during its run get the ones after - /// it, so code that an earlier script defined keeps a tag of its own. + /// it, so code that an earlier script defined keeps a tag of its own. [`UNKNOWN_SOURCE`] + /// before the first script, for a script the realm did not compile, and once the tags are used + /// up. script_tag: u32, } @@ -169,7 +178,7 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: UNKNOWN_SOURCE } } /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default @@ -225,11 +234,13 @@ impl Realm { /// Compiles `source` and readies the realm for a run. fn compile(&mut self, source: &str) -> Result, RunError> { + self.script_tag = UNKNOWN_SOURCE; let interp = &mut self.interp; if let Some(message) = &interp.install_error { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); } interp.fuel = interp.limits.fuel; + interp.flattened_fuel.set(0); interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; @@ -242,7 +253,7 @@ impl Realm { interp.finally_sites.clear(); interp.error_mark = None; let strict = interp.options.strict; - interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1); + interp.evaluated = next_tag(interp.evaluated); self.script_tag = interp.evaluated; interp.source_tag = self.script_tag; let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)); @@ -395,6 +406,28 @@ impl Realm { } } + /// The source tag of the code the last error's `line` and `column` are in, which stays the same + /// from run to run: each script given to [`Realm::run`] or [`Realm::start`] and each source a + /// [`crate::HostItem::Evaluator`] gives gets the next tag, so a function an earlier script + /// defined has its script's tag. [`UNKNOWN_SOURCE`] for code that `eval` or `Function` + /// compiled, an error with no place, and sources given once the tags are used up. + pub fn error_tag(&self) -> u32 { + self.interp.error_tag + } + + /// The source tag ([`Realm::error_tag`]) of the script the last [`Realm::run`] or + /// [`Realm::start`] compiled; the `n`th source an evaluator gave during that run has this tag + /// plus `n`. [`UNKNOWN_SOURCE`] when that call compiled nothing. + pub fn script_tag(&self) -> u32 { + self.script_tag + } + + /// Tests: makes `tag` the last source tag given out. + #[cfg(test)] + pub(crate) fn set_last_tag(&mut self, tag: u32) { + self.interp.evaluated = tag; + } + /// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no /// `catch` clause took that error's exception on its way out; `None` otherwise. pub fn error_mark(&self) -> Option { diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf54..eed08f96bd 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -98,7 +98,7 @@ fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> { /// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must /// grow. -fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { +pub(crate) fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { if list.len() == list.capacity() { let more = list.capacity().max(4); held.add(interp, more.saturating_mul(size_of::()))?; @@ -109,10 +109,11 @@ fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, it } /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's -/// bytes are reserved and added to the heap, and its units charged as fuel, first. +/// bytes are reserved and added to the heap, and its units charged as fuel, first. The rope stays +/// rooted while the copy is reserved, so what it holds is still counted. pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult> { if s.is_rope() { - reserve_units(interp, s.len())?; + interp.rooted(&[Value::String(s.clone())], |interp| reserve_units(interp, s.len()))?; } Ok(s.flat()) } @@ -179,7 +180,7 @@ fn clamp(n: f64, len: usize) -> usize { /// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes /// fuel. -fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { +pub(crate) fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { if needle.len() > hay.len() { return Ok(None); } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index c873bdaf59..01fc613cc3 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -309,6 +309,131 @@ fn flattening_a_rope_counts_against_the_heap() { } } +#[test] +fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { + // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + let not_stopped: Vec<_> = + ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { + // Ropes of 262,145 units, each about half the heap once flat: one fits, two don't. Each is + // made by a function, so nothing but the native code holds it while it is flattened. + let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; }"; + let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; + let run = |read: &str| Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + assert!(run("f('a')[0]").is_ok(), "one flattened copy fits"); + let not_stopped: Vec<_> = [ + "({ valueOf: function () { return f('a'); } }) < ({ valueOf: function () { return f('b'); } })", + "JSON.stringify({ toJSON: function () { return f('a'); } })", + "[1, 2].join({ toString: function () { return f('a'); } })", + "[{ toString: function () { return f('a'); } }].join('')", + ] + .into_iter() + .filter(|read| !matches!(run(read), Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { + // `mk` returns a rope whose left half is a flat string of about half the heap, held by + // nothing else. Its flattened copy is the other half, so the two don't fit together. + let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ + function mk() { var a = f('a'); a[0]; return a + 'b'; }"; + let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; + let not_stopped: Vec<_> = [ + "+({ valueOf: mk })", + "({})[{ toString: mk }]", + "({ valueOf: mk }) == 1", + "++({ get x() { return mk(); } }).x", + "JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())", + ] + .into_iter() + .filter(|read| { + let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_string_object_counts_its_flattened_rope_against_the_heap() { + // The same three ropes, each wrapped in a String object and read by index. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z'; \ + String.prototype.first = function () { return this[0]; };"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + // Wrapping alone reads no units, so it makes no copy. + let lengths = + Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length")); + assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0))); + // Two copies fit. The third read finds no room for its copy and reads the unit along the + // rope, so the live heap stays within the limit. + for (read, expected) in [ + ("Object(X)[0]", "xxx"), + ("new String(X)[0]", "xxx"), + ("0 in Object(X)", "truetruetrue"), + ("Object(X).hasOwnProperty(0)", "truetruetrue"), + ("X.first()", "xxx"), + ("Object(X)[524288]", "undefinedyz"), + ] { + let src = format!("{build} '' + ({}) + ({}) + ({})", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let mut realm = Realm::with_options(Options::default(), limits.clone()); + assert_eq!(realm.run(&src), Ok(JsValue::String(expected.into())), "{read}"); + let interp = realm.interp(); + interp.collect_garbage(); + assert!(interp.heap.bytes <= limits.heap_bytes, "{read}: {} bytes live", interp.heap.bytes); + } + let wrapped = + Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()")); + assert_eq!(wrapped, Ok(JsValue::String("yzx".into()))); +} + +#[test] +fn a_string_object_takes_fuel_for_the_rope_it_flattens() { + let limits = Limits { fuel: 40_000, ..Limits::default() }; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object(s).length"; + assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); + for read in ["Object(s)[0]", "0 in new String(s)"] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {read}")); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{read}: {e:?}"); + } + // A run that ends with an error right after the read leaves the next run its whole fuel. + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 100_000, ..Limits::default() }); + let defined = realm.run("var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object.defineProperty(new String(s), 0, { value: 'a' })"); + assert!(matches!(defined, Err(ref e) if e.kind == RunErrorKind::Thrown), "{defined:?}"); + assert!(realm.run("for (var j = 0; j < 5000; j++) {} j").is_ok()); +} + +#[test] +fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() { + // `g` leaves a garbage string, so the heap is close to its limit when the rope receiver of a + // non-strict function is boxed. The callee and the argument object are off the stack by + // then, and must survive anything boxing the receiver does. + let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \ + function mk() { var a = f('a'); a[0]; return a + 'b'; } 0"; + let mut realm = Realm::with_options(Options::default(), Limits::default()); + assert_eq!(realm.run(build), Ok(JsValue::Number(0.0))); + let interp = realm.interp(); + interp.collect_garbage(); + interp.limits.heap_bytes = interp.heap.bytes + 2 * crate::heap::string_bytes(131_073) + (64 << 10); + interp.reset_gc_threshold(); + let src = "(function () { var g = f('g'); g[0]; })(); (function (o) { return o.x; }).call(mk(), { x: 1 })"; + assert_eq!(realm.run(src), Ok(JsValue::Number(1.0))); +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index e164001ad9..a0fbcd4a53 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -163,6 +163,34 @@ impl Str { flat } + /// The code unit at `i` read along the rope without flattening it, visiting at most + /// `max_nodes` nodes: `None` when that is not enough, otherwise the unit (when `i` is in + /// range) and the number of nodes visited. + pub(crate) fn unit_at(&self, mut i: usize, max_nodes: u64) -> Option<(Option, u64)> { + let mut node = self.clone(); + let mut visited = 0u64; + loop { + visited += 1; + if visited > max_nodes { + return None; + } + let next = match &*node.0.borrow() { + Node::Flat(f) => return Some((f.get(i).copied(), visited)), + Node::Concat { left, right, .. } => { + let left_len = left.len(); + if i < left_len { + left.clone() + } else { + i -= left_len; + right.clone() + } + } + Node::Taken => return Some((None, visited)), + }; + node = next; + } + } + pub(crate) fn to_rust_string(&self) -> String { String::from_utf16_lossy(&self.flat()) } 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_builtins.rs b/crates/js/src/xml_builtins.rs index c74b9fac2e..9cd797f48a 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -14,6 +14,7 @@ use crate::value::{Str, Value}; use crate::xml::{ Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, }; +use crate::xpath::xpath; const SETTINGS: [(&str, Value); 5] = [ ("ignoreComments", Value::Bool(false)), @@ -45,7 +46,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { method(interp, xml, "settings", settings, 0)?; method(interp, xml, "setSettings", set_settings, 1)?; method(interp, xml, "defaultSettings", default_settings, 0)?; - let xml_methods: [(&'static str, NativeFn, u32); 39] = [ + let xml_methods: [(&'static str, NativeFn, u32); 40] = [ ("addNamespace", add_namespace, 1), ("appendChild", append_child, 1), ("attribute", attribute, 1), @@ -85,6 +86,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in xml_methods { method(interp, xml_proto, name, f, n)?; @@ -92,7 +94,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { let list = native(interp, "XMLList", xml_list_ctor, 1, NativeKind::Plain, true)?; constructor(interp, "XMLList", list, list_proto)?; - let list_methods: [(&'static str, NativeFn, u32); 21] = [ + let list_methods: [(&'static str, NativeFn, u32); 22] = [ ("attribute", attribute, 1), ("attributes", attributes, 0), ("child", child, 1), @@ -114,6 +116,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in list_methods { method(interp, list_proto, name, f, n)?; @@ -665,7 +668,7 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> /// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit /// of fuel for each node it climbs through and for each declaration it reads. -fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { +pub(crate) fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { let mut out: Vec = Vec::new(); let mut seen: HashSet> = HashSet::new(); let mut cur = x; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 9fb9452816..f9f66c8c26 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -372,7 +372,7 @@ fn the_xml_methods_behave_as_specified() { assert_eq!(string("(1).toString()"), "1"); assert_eq!(string("().toString()"), "\n \n"); truth("var x = ; x.valueOf() === x"); - truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); + truth("typeof XML.prototype.xpath === 'function' && typeof XMLList.prototype.xpath === 'function' && XML.prototype.xpath.length === 1"); truth("var x = ; x.b.attributes !== undefined"); } @@ -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)"); @@ -1182,6 +1279,471 @@ fn an_interrupt_stops_serialization() { assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); } +// ---- xpath ---- + +/// A script function that joins the local names of a list's items. +const NAMES: &str = "function names(l) { var s = ''; for each (var n in l) s += n.localName(); return s; }"; + +#[test] +fn xpath_selects_child_attribute_and_text_nodes() { + for check in [ + "xml.xpath('book').length() === 4", + "xml.xpath('book[2]/title') == 'Oz'", + "xml.xpath('book[last()]/title') == 'Opera'", + "xml.xpath('book[position() < 3]').length() === 2", + "xml.xpath('book[year > 1890]/title').length() === 3", + "xml.xpath('book[count(author) > 1]/title') == 'Alice'", + "xml.xpath('book[@category = \"CHILDREN\"]/title').length() === 2", + "xml.xpath('//author').length() === 6", + "xml.xpath('book/@category').length() === 4 && xml.xpath('book/@category')[0].nodeKind() === 'attribute'", + "xml.xpath('book[1]/title/text()')[0].nodeKind() === 'text' && xml.xpath('book[1]/title/text()') == 'Cookbook'", + "xml.xpath('book[1]/title/@lang') == 'en'", + "xml.xpath('nothing').length() === 0", + "xml.xpath('child::book[3]/child::author[2]') == 'B'", + "xml.xpath('*').length() === 4 && xml.xpath('book/node()').length() === 14", + ] { + truth(&format!("{BOOKS} {check}")); + } +} + +#[test] +fn xpath_returns_the_nodes_of_the_tree() { + truth(&format!("{BOOKS} xml.book[1].title[0].xpath('..')[0] === xml.book[1]")); + truth(&format!("{BOOKS} xml.xpath('book[2]')[0] === xml.book[1] && xml.xpath('.')[0] === xml")); + truth(&format!("{BOOKS} xml.xpath('book[1]/year')[0].setChildren('2000'); xml.book[0].year == '2000'")); + truth(&format!("{BOOKS} xml.xpath('book/@category')[1].setName('kind'); xml.book[1].@kind == 'CHILDREN'")); + truth("var x = t; x.a.text()[0].xpath('..')[0] === x.a[0]"); +} + +#[test] +fn xpath_absolute_paths_start_above_the_topmost_element() { + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/bookstore/book').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/*')[0] === xml")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('//title').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/title').length() === 0")); + truth("var d = ; d.e[0].xpath('/d')[0] === d"); + for src in ["().xpath('/')", "().xpath('..')", "().xpath('ancestor-or-self::node()')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_walks_every_axis() { + let tree = "var x = ;"; + for (expr, want) in [ + ("x.a[0].xpath('following-sibling::*')", "df"), + ("x.f[0].xpath('preceding-sibling::*')", "ad"), + ("x.f[0].xpath('preceding-sibling::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor::*')", "rd"), + ("x.d.e[0].xpath('ancestor::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor-or-self::*')", "rde"), + ("x.a.b[0].xpath('following::*')", "cdef"), + ("x.d.e[0].xpath('preceding::*')", "abc"), + ("x.d.e[0].xpath('preceding::*[1]')", "c"), + ("x.xpath('descendant::*')", "abcdef"), + ("x.xpath('descendant-or-self::*')", "rabcdef"), + ("x.a[0].xpath('self::a')", "a"), + ("x.a[0].xpath('self::b')", ""), + ("x.a.b[0].xpath('parent::*')", "a"), + ("x.xpath('//*[2]')", "cd"), + ("x.xpath('(//*)[2]')", "a"), + ("x.xpath('d/e/../preceding-sibling::*/c')", "c"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + let attr = "var x = ;"; + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('following::*'))")), "bc"); + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('ancestor::*'))")), "ra"); + truth(&format!("{attr} x.a.@p[0].xpath('following-sibling::node()').length() === 0 && x.a.@p[0].xpath('preceding::*').length() === 0")); +} + +#[test] +fn xpath_results_are_in_document_order_without_repeats() { + let tree = "var x = ;"; + for (expr, want) in [ + ("x.xpath('c | a | //b')", "abbc"), + ("x.xpath('a | a')", "a"), + ("x.xpath('//b/..')", "ra"), + ("x.xpath('//*/ancestor::*')", "ra"), + ("x.c[0].xpath('preceding::*[position() < 3]')", "bb"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + truth(&format!("{tree} x.xpath('c | @p')[0].nodeKind() === 'attribute' && x.xpath('c | @p').length() === 2")); +} + +#[test] +fn xpath_names_match_by_namespace_uri() { + let tree = "var x = ;"; + for check in [ + "x.xpath('p:a').length() === 2", + "x.xpath('a').length() === 1 && x.xpath('a')[0].name().uri === ''", + "x.xpath('p:*').length() === 2", + "x.xpath('*').length() === 4", + "x.xpath('*[local-name() = \"a\"]').length() === 4", + "x.xpath('*[namespace-uri() = \"urn:s\"]').length() === 1", + "x.xpath('*[name() = \"p:a\"]').length() === 1", + ] { + truth(&format!("{tree} {check}")); + } + truth("var x = ; x.xpath('a').length() === 0 && x.xpath('*').length() === 1"); + truth("var x = ; default xml namespace = 'urn:d'; x.xpath('a').length() === 1"); + truth("var x = ; x.xpath('a/@xml:lang') == 'en'"); + let rebound = "var x = ;"; + truth(&format!("{rebound} x.xpath('//p:a').length() === 1 && x.xpath('//p:a')[0].parent() === x")); + truth(&format!("{rebound} x.b[0].xpath('p:a').length() === 1 && x.b[0].xpath('/r/p:a').length() === 0")); + assert_eq!(thrown_name("().xpath('z:a')"), "TypeError"); +} + +#[test] +fn xpath_namespace_nodes_can_be_tested_but_not_returned() { + let tree = "var x = ;"; + truth(&format!("{tree} x.xpath('self::*[namespace::xml]').length() === 1")); + truth(&format!("{tree} x.xpath('a[count(namespace::*) = 3]').length() === 1")); + truth(&format!("{tree} x.xpath('a[namespace::q = \"urn:q\"]').length() === 1 && x.xpath('self::*[namespace::q]').length() === 0")); + truth(&format!("{tree} x.xpath('a[namespace::*[name() = \"p\"]/.. = .]').length() === 1")); + assert_eq!(thrown_name(&format!("{tree} x.xpath('namespace::*')")), "TypeError"); +} + +#[test] +fn xpath_variables_come_from_the_object_properties() { + truth(&format!("{BOOKS} xml.xpath('book[title = $t]/author', {{ t: 'Oz' }}) == 'Baum'")); + truth(&format!("{BOOKS} xml.xpath('book[$n]/title', {{ n: 3 }}) == 'Alice'")); + truth(&format!("{BOOKS} xml.xpath('book[$yes]', {{ yes: true }}).length() === 4 && xml.xpath('book[$no]', {{ no: false }}).length() === 0")); + truth(&format!("{BOOKS} xml.xpath('$b/title', {{ b: xml.book[1] }}) == 'Oz'")); + truth(&format!("{BOOKS} xml.xpath('$l/year', {{ l: xml.book.(@category == 'CHILDREN') }}).length() === 2")); + truth(&format!("{BOOKS} xml.xpath('book[year = $y]/title', {{ y: {{ toString: function () {{ return '1922'; }} }} }}) == 'Opera'")); + truth(&format!( + "{BOOKS} var o = {{}}; Object.defineProperty(o, 'v', {{ get: function () {{ return 'Alice'; }} }}); xml.xpath('book[title = $v]', o).length() === 1" + )); + for src in + ["().xpath('$v')", "().xpath('$v', {})", "().xpath('$v', { v: undefined })", "().xpath('a', 5)", "().xpath('a', null)"] + { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_keeps_the_items_of_a_list_variable_that_a_later_getter_empties() { + let setup = "var l = <>;"; + let action = "var o = { get v() { return l; }, get w() { delete l[0]; l = null; squeeze(100000000); return 1; } }; + var r = ().xpath('$v/b | $v[$w]', o); r.length() === 2 && r[0].localName() === 'a' && r[1].localName() === 'b'"; + assert_eq!(after_setup(setup, action), Ok(JsValue::Bool(true))); +} + +#[test] +fn xpath_functions_and_operators_follow_xpath_1() { + let tree = "var x = 12x y12.5;"; + let checks = [ + "last() = 1 and position() = 1", + "count(a) = 2", + "count(id('a')) = 0", + "local-name() = 'r' and name(a) = 'a' and namespace-uri() = ''", + "local-name(nothing) = '' and name(@xml:lang) = 'xml:lang'", + "string(a) = '1' and string() = '12x y12.5' and string(nothing) = ''", + "concat('a', 'b', 'c') = 'abc'", + "starts-with('abc', 'ab') and not(starts-with('abc', 'b'))", + "contains('abc', 'bc') and contains('abc', '') and not(contains('abc', 'd'))", + "substring-before('1999/04/01', '/') = '1999' and substring-before('abc', 'd') = ''", + "substring-after('1999/04/01', '/') = '04/01' and substring-after('abc', '') = 'abc'", + "substring('12345', 2, 3) = '234' and substring('12345', 2) = '2345'", + "substring('12345', 1.5, 2.6) = '234' and substring('12345', 0, 3) = '12'", + "substring('12345', 0 div 0, 3) = '' and substring('12345', 1, 0 div 0) = ''", + "substring('12345', -42, 1 div 0) = '12345' and substring('12345', -1 div 0, 1 div 0) = ''", + "string-length('abc') = 3 and string-length(b) = 4 and string-length() = 10", + "normalize-space(b) = 'x y' and normalize-space(' x \t y ') = 'x y' and normalize-space(' ') = ''", + "translate('bar', 'abc', 'ABC') = 'BAr' and translate('--aaa--', 'abc-', 'ABC') = 'AAA'", + "boolean(a) and not(boolean(nothing)) and boolean('x') and not(boolean('')) and not(boolean(0)) and not(boolean(0 div 0))", + "true() and not(false())", + "lang('en') and lang('EN-us') and not(lang('fr')) and a[lang('en')]", + "number(n) = 12.5 and number(' -3 ') = -3 and string(number('1e3')) = 'NaN' and string(number('+1')) = 'NaN'", + "sum(a) = 3 and sum(nothing) = 0", + "floor(2.5) = 2 and ceiling(2.1) = 3 and floor(-2.5) = -3", + "round(2.5) = 3 and round(-2.5) = -2 and round(2.4) = 2 and string(round(-0.4)) = '0'", + "string(1 div 0) = 'Infinity' and string(-1 div 0) = '-Infinity' and string(0 div 0) = 'NaN'", + "string(1.5) = '1.5' and string(2) = '2' and string(-0.25) = '-0.25' and string(1 div 3) = '0.3333333333333333'", + "string(1000000 * 1000000 * 1000000 * 1000) = '1000000000000000000000'", + "7 mod 3 = 1 and -7 mod 3 = -1 and 5 div 2 = 2.5 and 2 - 3 = -1 and --1 = 1 and -(1 + 1) = -2", + "1 + 2 * 3 = 7 and (1 + 2) * 3 = 9 and 3 > 2 > 1 = false()", + "a = 2 and a = '1' and a != 1 and a > 1 and not(a > 2) and a < 2 and 1 < a and 2 >= a", + "a = a and a != a and not(a[1] != a[1]) and not(nothing = nothing) and not(nothing != a) and a < a", + "true() = a and false() = nothing and '1' = 1 and true() = 'x' and 1 = true() and not(0 = true())", + "a > false() and not(a > true()) and nothing < true() and not(nothing >= true())", + "1 < 2 and 2 > 1 or false()", + "false() and 1 div 0 or true()", + "'a' < 'b' = false() and '1' < '2'", + "*[2] = 2 and *[last()] = 12.5", + "(a | b)[2] = 2", + ]; + for check in checks { + let expr = format!("self::*[{check}]"); + truth(&format!("{tree} x.xpath({expr:?}).length() === 1")); + } +} + +#[test] +fn xpath_throws_a_type_error_for_a_value_that_is_not_a_node_set() { + for expr in ["count(book)", "string(book)", "1", "'a'", "true()", "book = 1", "-book"] { + let src = format!("{BOOKS} try {{ xml.xpath({expr:?}); 'no error' }} catch (e) {{ e.name + ': ' + e.message }}"); + assert_eq!(string(&src), "TypeError: the XPath expression does not evaluate to a node-set", "{expr}"); + } +} + +#[test] +fn xpath_errors_name_their_cause() { + for expr in [ + "", + " ", + "/[1]", + "book[", + "book]", + "(", + ")", + "1 +", + "book/", + "@", + "foo::bar", + ".[1]", + "..[1]", + "'abc", + "$", + "book book", + "!", + "a//", + ",", + "count(", + "count(1,", + "a::", + "::a", + "a:", + "1..2", + "book[1]]", + "count(book))", + "//", + "/ /a", + "(1)(2)", + "processing-instruction(1)", + "node(1)", + "*:a", + "a:b:c", + ] { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "SyntaxError", "{expr:?}"); + } + for expr in + ["foo()", "count()", "count(1)", "p:count(book)", "sum('1')", "1 | book", "'a'/b", "'a'[1]", "concat('a')", "name(1)", "substring('a')"] + { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "TypeError", "{expr:?}"); + } + for src in ["().xpath()", "().xpath(5)", "().xpath(new String('a'))", "XML.prototype.xpath.call({}, 'a')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_on_an_xml_list_runs_on_each_element_in_turn() { + truth( + "var l = <>123; l.xpath('b').length() === 3 && l.xpath('b').toXMLString() === '1\\n2\\n3'", + ); + truth("var l = <>123; l.xpath('b[1]').length() === 2"); + truth("var x = ; var l = x.*; var p = l.xpath('..'); p.length() === 2 && p[0] === x && p[1] === x"); + truth("var x = t; var l = x.a.text() + x.a; l.xpath('.').length() === 1 && l.xpath('.')[0] === x.a[0]"); + truth("var x = ; (x.@p + x.comments()).xpath('.').length() === 0"); + truth("new XMLList().xpath('a').length() === 0 && new XMLList().xpath('z:a').length() === 0"); + truth("var l = <>; l.xpath('p:b').length() === 2"); + assert_eq!(thrown_name("var l = <>; l.xpath('p:b')"), "TypeError"); +} + +#[test] +fn xpath_expressions_of_any_depth_use_no_native_stack() { + let deep = format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000)); + assert_eq!(thrown_name(&format!("().xpath('{deep}')")), "SyntaxError"); + let predicates = format!("{}{}", "a[".repeat(100_000), "]".repeat(100_000)); + assert_eq!(thrown_name(&format!("().xpath('{predicates}')")), "SyntaxError"); + let sum = format!("1{}", " + 1".repeat(100_000)); + truth(&format!("().xpath('self::*[{sum} = 100001]').length() === 1")); + let negated = format!("{}1", "-".repeat(100_001)); + truth(&format!("().xpath('self::*[{negated} = -1]').length() === 1")); + let union = format!("a{}", " | a".repeat(50_000)); + truth(&format!("().xpath('{union}').length() === 1")); + let mut shallow = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + let seven = format!("{}1{}", "(".repeat(6), ")".repeat(6)); + assert_eq!(shallow.run(&format!("().xpath('self::*[{seven}]').length()")), Ok(JsValue::Number(1.0))); + let eight = format!("{}1{}", "(".repeat(8), ")".repeat(8)); + assert_eq!(shallow.run(&format!("try {{ ().xpath('{eight}') }} catch (e) {{ e.message }}")), Ok(JsValue::String("nesting too deep".into()))); +} + +#[test] +fn xpath_stops_at_the_fuel_and_heap_limits_leaving_the_tree() { + let setup = "var x = ; for (var i = 0; i < 6; i++) x.appendChild({i});"; + let check = "x.a.length() === 6 && x.a[5].b == '5' && x.*.length() === 6"; + assert!(fuel_stops(setup, "x.xpath('//b[. > 2]/preceding::*')", check) > 0); + assert!(heap_stops(setup, "x.xpath((SPARE, '//b[contains(concat(., ., .), \"3\")] | //a'))", check) > 0); + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 500; i++) x.appendChild();").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +#[test] +fn xpath_walks_of_deep_trees_and_long_lists_count_against_the_heap_limit() { + let deep = "var s = ''; for (var i = 0; i < 5000; i++) s += ''; s += ''; for (var i = 0; i < 5000; i++) s += ''; var x = new XML(s);"; + for (action, room) in [ + ("x.xpath('descendant::c').length()", JsValue::Number(0.0)), + ("x.xpath('self::*[string() = \"q\"]').length()", JsValue::Number(0.0)), + ("x.xpath('descendant::b | self::a').length()", JsValue::Number(2.0)), + ] { + for (spare, want) in [(20_000, Err(RunErrorKind::Limit(LimitKind::Heap))), (50_000_000, Ok(room.clone()))] { + let mut r = Realm::with_options(Options { max_depth: 1_000_000, ..e4x() }, Limits::default()); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(deep).unwrap(); + assert_eq!(r.run(&format!("squeeze({spare}); {action}")).map_err(|e| e.kind), want, "{action} {spare}"); + } + } + let long = "var s = ''; for (var i = 0; i < 20000; i++) s += ''; var l = new XML(s + '').a;"; + assert_eq!(after_setup(long, "squeeze(50000); l.xpath('self::c').length()"), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(long, "squeeze(50000000); l.xpath('self::c').length()"), Ok(JsValue::Number(0.0))); +} + +#[test] +fn xpath_reading_a_long_string_takes_fuel_for_its_length() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var x = ; for (var i = 0; i < 200; i++) x.appendChild(y);"; + for expr in ["a[string-length($s) = 1]", "a[starts-with($s, $s) = false()]", "a[$s = concat('', 'x')]", "a[number($s) = 1]", "a[. = $s]"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("x.xpath({expr:?}, {{ s: s }}).length()"); + assert_eq!(r.run(&src), Ok(JsValue::Number(0.0)), "{expr}"); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_string_functions_hold_the_bytes_they_build() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var d = '1'; for (var i = 0; i < 20; i++) d += d; var o = { s: s, d: d };"; + for (expr, spare) in [("translate('a', 'b', $s) = 'a'", 1_000_000), ("number($d) > 1", 500_000), ("substring($s, 1) != 'z'", 4_000_000)] { + let action = |spare: u32| format!("squeeze({spare}); ().xpath(\"self::*[{expr}]\", o).length()"); + assert_eq!(after_setup(setup, &action(spare)), Err(RunErrorKind::Limit(LimitKind::Heap)), "{expr}"); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0)), "{expr}"); + } +} + +#[test] +fn xpath_string_variables_hold_the_bytes_of_their_flat_copy() { + // The setup ends with `0` so that its completion value does not flatten `s`. + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var o = { s: s }; 0;"; + let action = |spare: u32| format!("squeeze({spare}); ().xpath('self::*[$s]', o).length()"); + assert_eq!(after_setup(setup, &action(1_000_000)), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0))); +} + +#[test] +fn xpath_substring_searches_take_fuel_for_every_position_tried() { + let setup = "var h = 'a'; for (var i = 0; i < 16; i++) h += h; var n = h.substring(0, 4095) + 'b'; var o = { h: h, n: n };"; + for expr in ["contains($h, $n)", "substring-before($h, $n) = ''", "substring-after($h, $n) = ''"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("().xpath(\"self::*[{expr}]\", o).length()"); + let want = if expr.starts_with("contains") { 0.0 } else { 1.0 }; + assert_eq!(r.run(&src), Ok(JsValue::Number(want)), "{expr}"); + r.set_limits(Limits { fuel: 5_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_lang_takes_fuel_for_every_attribute_it_reads() { + let setup = "var s = '');"; + let mut r = realm(); + r.run(setup).unwrap(); + let src = "x.xpath('a[lang(\"en\")]').length()"; + assert_eq!(r.run(src), Ok(JsValue::Number(0.0))); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +#[test] +fn xpath_on_a_large_tree_ends_within_the_default_limits() { + let setup = "var x = ; for (var i = 0; i < 3000; i++) x.appendChild();"; + truth(&format!("{setup} x.xpath('//a[@n = 2999]/b').length() === 1 && x.xpath('//b/..').length() === 3000")); + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 2000; i++) x.appendChild();").unwrap(); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run("x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +const XPATH_PIECES: &[&str] = &[ + "/", + "//", + "a", + "b", + "*", + "@", + "@*", + ".", + "..", + "[", + "]", + "(", + ")", + "1", + "'x'", + "$v", + "|", + "+", + "-", + "=", + "!=", + "<", + ">=", + " and ", + " or ", + " div ", + " mod ", + "child::", + "following::", + "preceding::", + "ancestor::", + "namespace::", + "node()", + "text()", + "count(", + "last()", + "position()", + "concat(", + "substring(", + ",", + "p:", + "p:*", + "xml:lang", + "::", + ":", + "'", + " ", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn random_xpath_expressions_select_nodes_or_throw(parts in proptest::collection::vec(0..XPATH_PIECES.len(), 0..30)) { + let expr: String = parts.iter().map(|&i| XPATH_PIECES[i]).collect(); + let quoted = format!("{expr:?}"); + let src = format!("var x = 23t; typeof XML.prototype.xpath === 'function' && x.a[0].xpath({quoted}, {{ v: 1 }}).length() >= 0"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", expr), + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Thrown | RunErrorKind::Limit(_)), "{expr:?}: {e:?}"), + } + } +} + // ---- generated input ---- const PIECES: &[&str] = &[ diff --git a/crates/js/src/xpath.rs b/crates/js/src/xpath.rs new file mode 100644 index 0000000000..30707e4de0 --- /dev/null +++ b/crates/js/src/xpath.rs @@ -0,0 +1,2377 @@ +//! `xpath` on XML and XMLList values: XPath 1.0 expressions +//! () evaluated on E4X trees. +//! +//! An expression is read into tokens, then into a syntax tree whose nodes live in one arena, and is +//! evaluated with an explicit task stack, so neither step uses native recursion. Brackets nest at +//! most [`crate::Options::max_depth`] deep. Every token, task and visited node takes fuel, and the +//! token, tree, node-set and string buffers are held against the heap limit while they live. +//! +//! `/` is a root node above the topmost node of the context node's tree. A node-set result is +//! returned as an XMLList of the selected nodes themselves, in document order with attributes +//! before children. The root node and namespace nodes can be selected and tested inside an +//! expression, but a result that holds one throws a `TypeError`, since E4X has no such value. The +//! tree keeps no DTD, so `id()` selects nothing. Prefixes in the expression are read from the +//! namespaces in scope on the context element, and `xml` is always bound. + +use std::collections::{HashMap, HashSet}; +use std::mem::replace; +use std::rc::Rc; + +use crate::builtins::arg; +use crate::error::TOO_DEEP; +use crate::heap::{Key, ObjId, XmlKind, XmlName, XmlNs, key}; +use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; +use crate::regexp_vm::use_fuel_n; +use crate::string_builtins::{self, charge_units, flat_charged, push_held}; +use crate::value::Value; +use crate::xml::{Xv, is_name_part, is_name_start, is_xml_space, valid_xml_units}; +use crate::xml_builtins::scope_of; + +const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; +const COLON: u16 = b':' as u16; +const STAR: u16 = b'*' as u16; +const SLASH: u16 = b'/' as u16; +const DOT: u16 = b'.' as u16; +const EQUALS: u16 = b'=' as u16; +const DASH: u16 = b'-' as u16; +/// The estimated bytes of a syntax tree node's own lists, beyond its slot in the arena. +const EXPR_BYTES: usize = 64; +/// The estimated bytes of one entry of a hash map or set the evaluator keeps. +const ENTRY_BYTES: usize = 64; + +fn empty() -> Key { + Rc::from(Vec::new()) +} + +fn syntax(interp: &mut Interp, message: &str) -> Abrupt { + interp.throw(ErrorType::Syntax, message) +} + +fn internal() -> Abrupt { + Abrupt::Internal("XPath evaluation state".into()) +} + +fn is_digit(c: u16) -> bool { + (0x30..=0x39).contains(&c) +} + +// ---- tokens ---- + +#[derive(Clone, Copy, PartialEq)] +enum Punct { + LParen, + RParen, + LBracket, + RBracket, + Dot, + DotDot, + At, + Comma, + ColonColon, + Slash, + SlashSlash, + Pipe, + Plus, + Minus, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +/// A token as the lexer reads it, before names and `*` are told apart by their position. +enum Raw { + Punct(Punct), + Star, + Name(Option, Key), + PrefixStar(Key), + Literal(Key), + Number(f64), + Var(Key), +} + +#[derive(Clone, Copy, PartialEq)] +enum Axis { + Ancestor, + AncestorOrSelf, + Attribute, + Child, + Descendant, + DescendantOrSelf, + Following, + FollowingSibling, + Namespace, + Parent, + Preceding, + PrecedingSibling, + Itself, +} + +const AXES: [(&str, Axis); 13] = [ + ("ancestor", Axis::Ancestor), + ("ancestor-or-self", Axis::AncestorOrSelf), + ("attribute", Axis::Attribute), + ("child", Axis::Child), + ("descendant", Axis::Descendant), + ("descendant-or-self", Axis::DescendantOrSelf), + ("following", Axis::Following), + ("following-sibling", Axis::FollowingSibling), + ("namespace", Axis::Namespace), + ("parent", Axis::Parent), + ("preceding", Axis::Preceding), + ("preceding-sibling", Axis::PrecedingSibling), + ("self", Axis::Itself), +]; + +impl Axis { + /// Whether the axis lists its nodes in reverse document order. + fn reverse(self) -> bool { + matches!(self, Axis::Ancestor | Axis::AncestorOrSelf | Axis::Preceding | Axis::PrecedingSibling) + } +} + +/// A node test. A prefix is resolved when the expression runs. +#[derive(Clone)] +enum Test { + Any, + Prefix(Key), + Name(Option, Key), + Node, + Text, + Comment, + Pi(Option), +} + +#[derive(Clone, Copy, PartialEq)] +enum Bin { + Or, + And, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + Add, + Sub, + Mul, + Div, + Mod, + Union, +} + +impl Bin { + fn precedence(self) -> u8 { + match self { + Bin::Or => 1, + Bin::And => 2, + Bin::Eq | Bin::Ne => 3, + Bin::Lt | Bin::Le | Bin::Gt | Bin::Ge => 4, + Bin::Add | Bin::Sub => 5, + Bin::Mul | Bin::Div | Bin::Mod => 6, + Bin::Union => 8, + } + } +} + +/// The precedence of unary minus: tighter than `*`, looser than `|`. +const NEG_PRECEDENCE: u8 = 7; + +#[derive(Clone, Copy, PartialEq)] +enum Func { + Last, + Position, + Count, + Id, + LocalName, + NamespaceUri, + Name, + String, + Concat, + StartsWith, + Contains, + SubstringBefore, + SubstringAfter, + Substring, + StringLength, + NormalizeSpace, + Translate, + Boolean, + Not, + True, + False, + Lang, + Number, + Sum, + Floor, + Ceiling, + Round, +} + +/// The core function library (XPath 1.0 section 4): each name, and its least and most arguments. +const FUNCS: [(&str, Func, usize, usize); 27] = [ + ("last", Func::Last, 0, 0), + ("position", Func::Position, 0, 0), + ("count", Func::Count, 1, 1), + ("id", Func::Id, 1, 1), + ("local-name", Func::LocalName, 0, 1), + ("namespace-uri", Func::NamespaceUri, 0, 1), + ("name", Func::Name, 0, 1), + ("string", Func::String, 0, 1), + ("concat", Func::Concat, 2, usize::MAX), + ("starts-with", Func::StartsWith, 2, 2), + ("contains", Func::Contains, 2, 2), + ("substring-before", Func::SubstringBefore, 2, 2), + ("substring-after", Func::SubstringAfter, 2, 2), + ("substring", Func::Substring, 2, 3), + ("string-length", Func::StringLength, 0, 1), + ("normalize-space", Func::NormalizeSpace, 0, 1), + ("translate", Func::Translate, 3, 3), + ("boolean", Func::Boolean, 1, 1), + ("not", Func::Not, 1, 1), + ("true", Func::True, 0, 0), + ("false", Func::False, 0, 0), + ("lang", Func::Lang, 1, 1), + ("number", Func::Number, 0, 1), + ("sum", Func::Sum, 1, 1), + ("floor", Func::Floor, 1, 1), + ("ceiling", Func::Ceiling, 1, 1), + ("round", Func::Round, 1, 1), +]; + +/// A token the parser reads: names and `*` with their roles decided (XPath 1.0 section 3.7). +enum Tok { + Punct(Punct), + Op(Bin), + Test(Test), + Axis(Axis), + Func(Func, &'static str), + Literal(Key), + Number(f64), + Var(Key), +} + +fn ncname_end(src: &[u16], at: usize) -> Option { + let first = *src.get(at)?; + if first == COLON || !is_name_start(first) { + return None; + } + let mut end = at + 1; + while src.get(end).is_some_and(|&c| c != COLON && is_name_part(c)) { + end += 1; + } + valid_xml_units(src.get(at..end)?).then_some(end) +} + +fn lex_name(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let end = ncname_end(src, at)?; + let first: Key = Rc::from(src.get(at..end)?); + if src.get(end) != Some(&COLON) || src.get(end + 1) == Some(&COLON) { + return Some((Raw::Name(None, first), end)); + } + if src.get(end + 1) == Some(&STAR) { + return Some((Raw::PrefixStar(first), end + 2)); + } + let local_end = ncname_end(src, end + 1)?; + Some((Raw::Name(Some(first), Rc::from(src.get(end + 1..local_end)?)), local_end)) +} + +fn lex_number(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let mut end = at; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + if src.get(end) == Some(&DOT) { + end += 1; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + } + let text = String::from_utf16(src.get(at..end)?).ok()?; + Some((Raw::Number(text.parse().ok()?), end)) +} + +fn lex_literal(src: &[u16], at: usize, quote: u16) -> Option<(Raw, usize)> { + let rest = src.get(at + 1..)?; + let len = rest.iter().position(|&c| c == quote)?; + Some((Raw::Literal(Rc::from(rest.get(..len)?)), at + len + 2)) +} + +fn lex_var(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let (Raw::Name(..), end) = lex_name(src, at + 1)? else { return None }; + Some((Raw::Var(Rc::from(src.get(at + 1..end)?)), end)) +} + +/// The token that starts at `src[at]`, and the offset after it. +fn lex_one(src: &[u16], at: usize, c: u16) -> Option<(Raw, usize)> { + if c != COLON && is_name_start(c) { + return lex_name(src, at); + } + let next = src.get(at + 1).copied(); + let p = |p: Punct, n: usize| Some((Raw::Punct(p), at + n)); + match char::from_u32(u32::from(c))? { + '(' => p(Punct::LParen, 1), + ')' => p(Punct::RParen, 1), + '[' => p(Punct::LBracket, 1), + ']' => p(Punct::RBracket, 1), + '@' => p(Punct::At, 1), + ',' => p(Punct::Comma, 1), + '|' => p(Punct::Pipe, 1), + '+' => p(Punct::Plus, 1), + '-' => p(Punct::Minus, 1), + '=' => p(Punct::Eq, 1), + '*' => Some((Raw::Star, at + 1)), + '/' if next == Some(SLASH) => p(Punct::SlashSlash, 2), + '/' => p(Punct::Slash, 1), + ':' if next == Some(COLON) => p(Punct::ColonColon, 2), + '!' if next == Some(EQUALS) => p(Punct::Ne, 2), + '<' if next == Some(EQUALS) => p(Punct::Le, 2), + '<' => p(Punct::Lt, 1), + '>' if next == Some(EQUALS) => p(Punct::Ge, 2), + '>' => p(Punct::Gt, 1), + '.' if next == Some(DOT) => p(Punct::DotDot, 2), + '.' if next.is_some_and(is_digit) => lex_number(src, at), + '.' => p(Punct::Dot, 1), + '"' | '\'' => lex_literal(src, at, c), + '$' => lex_var(src, at), + _ if is_digit(c) => lex_number(src, at), + _ => None, + } +} + +fn lex(interp: &mut Interp, held: &mut HeldBytes, src: &[u16]) -> JsResult> { + let mut out = Vec::new(); + let mut at = 0; + loop { + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + let Some(&c) = src.get(at) else { return Ok(out) }; + let Some((raw, end)) = lex_one(src, at, c) else { + return Err(syntax(interp, &format!("the XPath expression cannot be read at offset {at}"))); + }; + charge_units(interp, end - at)?; + push_held(interp, held, &mut out, raw)?; + at = end; + } +} + +fn units_are(units: &[u16], text: &str) -> bool { + units.iter().copied().eq(text.encode_utf16()) +} + +fn operator_name(name: &[u16]) -> Option { + [("and", Bin::And), ("or", Bin::Or), ("mod", Bin::Mod), ("div", Bin::Div)].into_iter().find(|(n, _)| units_are(name, n)).map(|(_, b)| b) +} + +fn punct_token(p: Punct) -> Tok { + match p { + Punct::Pipe => Tok::Op(Bin::Union), + Punct::Plus => Tok::Op(Bin::Add), + Punct::Minus => Tok::Op(Bin::Sub), + Punct::Eq => Tok::Op(Bin::Eq), + Punct::Ne => Tok::Op(Bin::Ne), + Punct::Lt => Tok::Op(Bin::Lt), + Punct::Le => Tok::Op(Bin::Le), + Punct::Gt => Tok::Op(Bin::Gt), + Punct::Ge => Tok::Op(Bin::Ge), + other => Tok::Punct(other), + } +} + +/// Whether a token at this point is an operator: there is a token before it, and it is not `@`, +/// `::`, `(`, `[`, `,` or an operator (XPath 1.0 section 3.7). +fn operator_position(last: Option<&Tok>) -> bool { + last.is_some_and(|t| { + !matches!( + t, + Tok::Punct(Punct::LParen | Punct::LBracket | Punct::At | Punct::Comma | Punct::Slash | Punct::SlashSlash) + | Tok::Op(_) + | Tok::Axis(_) + | Tok::Func(..) + ) + }) +} + +/// A name followed by `(`: a node type test, which also takes its `)`, or a function call. +/// Returns the token and how many raw tokens it takes. +fn call_or_node_type(interp: &mut Interp, prefix: Option<&Key>, name: &Key, after: &[Raw]) -> JsResult<(Tok, usize)> { + if prefix.is_none() { + let plain = [("node", Test::Node), ("text", Test::Text), ("comment", Test::Comment)].into_iter().find(|(n, _)| units_are(name, n)); + if let Some((_, test)) = plain { + return match after.first() { + Some(Raw::Punct(Punct::RParen)) => Ok((Tok::Test(test), 3)), + _ => Err(syntax(interp, "an XPath node type test takes no argument")), + }; + } + if units_are(name, "processing-instruction") { + return match after { + [Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(None)), 3)), + [Raw::Literal(target), Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(Some(target.clone()))), 4)), + _ => Err(syntax(interp, "processing-instruction() takes one string literal or none")), + }; + } + } + let found = FUNCS.iter().find(|(n, ..)| prefix.is_none() && units_are(name, n)); + match found { + Some(&(n, f, ..)) => Ok((Tok::Func(f, n), 2)), + None => { + let shown = String::from_utf16_lossy(name); + Err(interp.type_error(&format!("{shown} is not an XPath 1.0 function"))) + } + } +} + +fn classify(interp: &mut Interp, held: &mut HeldBytes, raw: &[Raw]) -> JsResult> { + let mut out: Vec = Vec::new(); + let mut at = 0; + while let Some(r) = raw.get(at) { + interp.use_fuel()?; + let operator = operator_position(out.last()); + let next = raw.get(at + 1); + let (tok, used) = match r { + Raw::Star if operator => (Tok::Op(Bin::Mul), 1), + Raw::Star => (Tok::Test(Test::Any), 1), + Raw::Name(None, n) if operator => match operator_name(n) { + Some(op) => (Tok::Op(op), 1), + None => return Err(syntax(interp, "an XPath operator is missing")), + }, + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::LParen))) => { + call_or_node_type(interp, p.as_ref(), n, raw.get(at + 2..).unwrap_or_default())? + } + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::ColonColon))) => { + let axis = AXES.iter().find(|(a, _)| p.is_none() && units_are(n, a)).map(|&(_, a)| a); + match axis { + Some(a) => (Tok::Axis(a), 2), + None => return Err(syntax(interp, &format!("{} is not an XPath axis", String::from_utf16_lossy(n)))), + } + } + Raw::Name(p, n) => (Tok::Test(Test::Name(p.clone(), n.clone())), 1), + Raw::PrefixStar(p) => (Tok::Test(Test::Prefix(p.clone())), 1), + Raw::Punct(Punct::ColonColon) => return Err(syntax(interp, "an XPath axis name is missing before ::")), + Raw::Punct(p) => (punct_token(*p), 1), + Raw::Literal(s) => (Tok::Literal(s.clone()), 1), + Raw::Number(n) => (Tok::Number(*n), 1), + Raw::Var(v) => (Tok::Var(v.clone()), 1), + }; + push_held(interp, held, &mut out, tok)?; + at += used; + } + Ok(out) +} + +// ---- the syntax tree ---- + +struct Step { + axis: Axis, + test: Test, + preds: Vec, +} + +enum Expr { + Bin(Bin, usize, usize), + Neg(usize), + Num(f64), + Lit(Key), + Var(usize), + Call(Func, Vec), + /// A location path, or a filter expression with its predicates, followed by steps. + Path { + filter: Option<(usize, Vec)>, + absolute: bool, + steps: Vec, + }, +} + +/// A parsed expression: the tree in its arena, its root, and the variables and prefixes it names. +struct Parsed { + exprs: Vec, + root: usize, + vars: Vec, + prefixes: Vec, + _held: HeldBytes, +} + +#[derive(Clone, Copy)] +enum Op { + Bin(Bin), + Neg, +} + +impl Op { + fn precedence(self) -> u8 { + match self { + Op::Bin(b) => b.precedence(), + Op::Neg => NEG_PRECEDENCE, + } + } +} + +/// A path expression being read. +#[derive(Default)] +struct Operand { + filter: Option<(usize, Vec)>, + absolute: bool, + steps: Vec, + /// The last step is `.` or `..`, which take no predicate. + abbreviated: bool, +} + +impl Operand { + fn bare_root(&self) -> bool { + self.absolute && self.steps.is_empty() && self.filter.is_none() + } +} + +enum FrameKind { + Top, + Group, + Pred, + Args(Func, &'static str, Vec), +} + +/// One bracket level: its operators and operands, read by operator precedence. +struct Frame { + kind: FrameKind, + ops: Vec, + vals: Vec, + operand: Option, +} + +impl Frame { + fn new(kind: FrameKind) -> Self { + Frame { kind, ops: Vec::new(), vals: Vec::new(), operand: None } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Want { + Operand, + Step, + Test(Axis), + After, +} + +struct Parser<'i> { + interp: &'i mut Interp, + held: HeldBytes, + exprs: Vec, + vars: Vec, + var_index: HashMap, + prefixes: HashSet, + frames: Vec, + want: Want, +} + +impl Parser<'_> { + fn error(&mut self, message: &str) -> Abrupt { + syntax(self.interp, message) + } + + fn push_expr(&mut self, e: Expr) -> JsResult { + self.held.add(self.interp, EXPR_BYTES)?; + push_held(self.interp, &mut self.held, &mut self.exprs, e)?; + Ok(self.exprs.len() - 1) + } + + fn top(&mut self) -> JsResult<&mut Frame> { + self.frames.last_mut().ok_or_else(internal) + } + + fn operand(&mut self) -> JsResult<&mut Operand> { + self.top()?.operand.as_mut().ok_or_else(internal) + } + + fn open(&mut self, kind: FrameKind) -> JsResult<()> { + if self.frames.len() >= self.interp.options.max_depth { + return Err(self.error(TOO_DEEP)); + } + self.held.add(self.interp, size_of::())?; + self.frames.push(Frame::new(kind)); + self.want = Want::Operand; + Ok(()) + } + + fn token(&mut self, tok: Tok) -> JsResult<()> { + match self.want { + Want::Operand => self.operand_token(tok), + Want::Step => self.step_token(tok), + Want::Test(axis) => match tok { + Tok::Test(test) => self.push_step(axis, test, false), + _ => Err(self.error("an XPath node test is missing")), + }, + Want::After => self.after_token(tok), + } + } + + fn primary(&mut self, e: Expr) -> JsResult<()> { + let id = self.push_expr(e)?; + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn operand_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Op(Bin::Sub) => { + self.top()?.ops.push(Op::Neg); + Ok(()) + } + Tok::Punct(Punct::Slash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.want = Want::Step; + Ok(()) + } + Tok::Punct(Punct::SlashSlash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.descendants_step() + } + Tok::Literal(s) => self.primary(Expr::Lit(s)), + Tok::Number(n) => self.primary(Expr::Num(n)), + Tok::Var(name) => { + let next = self.vars.len(); + let at = *self.var_index.entry(name.clone()).or_insert(next); + if at == next { + push_held(self.interp, &mut self.held, &mut self.vars, name)?; + } + self.primary(Expr::Var(at)) + } + Tok::Func(f, n) => self.open(FrameKind::Args(f, n, Vec::new())), + Tok::Punct(Punct::LParen) => self.open(FrameKind::Group), + Tok::Punct(Punct::RParen) if self.empty_call()? => self.close_paren(), + tok @ (Tok::Test(_) | Tok::Axis(_) | Tok::Punct(Punct::At | Punct::Dot | Punct::DotDot)) => { + self.top()?.operand = Some(Operand::default()); + self.step_token(tok) + } + _ => Err(self.error("an XPath operand is missing")), + } + } + + /// Whether the open bracket is a function call's, with nothing in it yet. + fn empty_call(&mut self) -> JsResult { + let f = self.top()?; + Ok(matches!(&f.kind, FrameKind::Args(_, _, args) if args.is_empty()) && f.ops.is_empty() && f.vals.is_empty() && f.operand.is_none()) + } + + fn descendants_step(&mut self) -> JsResult<()> { + self.operand()?.steps.push(Step { axis: Axis::DescendantOrSelf, test: Test::Node, preds: Vec::new() }); + self.want = Want::Step; + Ok(()) + } + + fn push_step(&mut self, axis: Axis, test: Test, abbreviated: bool) -> JsResult<()> { + if let Test::Prefix(p) | Test::Name(Some(p), _) = &test + && !self.prefixes.contains(p) + { + self.held.add(self.interp, ENTRY_BYTES)?; + self.prefixes.insert(p.clone()); + } + self.held.add(self.interp, size_of::())?; + let operand = self.operand()?; + operand.steps.push(Step { axis, test, preds: Vec::new() }); + operand.abbreviated = abbreviated; + self.want = Want::After; + Ok(()) + } + + fn step_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(Punct::Dot) => self.push_step(Axis::Itself, Test::Node, true), + Tok::Punct(Punct::DotDot) => self.push_step(Axis::Parent, Test::Node, true), + Tok::Punct(Punct::At) => { + self.want = Want::Test(Axis::Attribute); + Ok(()) + } + Tok::Axis(a) => { + self.want = Want::Test(a); + Ok(()) + } + Tok::Test(t) => self.push_step(Axis::Child, t, false), + tok if self.operand()?.bare_root() => { + self.want = Want::After; + self.after_token(tok) + } + _ => Err(self.error("an XPath step is missing")), + } + } + + fn after_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(p @ (Punct::LBracket | Punct::Slash | Punct::SlashSlash)) => { + let operand = self.operand()?; + let (bare, abbreviated) = (operand.bare_root(), operand.abbreviated); + if bare || (p == Punct::LBracket && abbreviated) { + return Err(self.error("an XPath predicate or step cannot follow here")); + } + match p { + Punct::LBracket => self.open(FrameKind::Pred), + Punct::Slash => { + self.want = Want::Step; + Ok(()) + } + _ => self.descendants_step(), + } + } + Tok::Op(b) => self.binary(b), + Tok::Punct(Punct::RParen) => self.close_paren(), + Tok::Punct(Punct::RBracket) => self.close_bracket(), + Tok::Punct(Punct::Comma) => self.next_argument(), + _ => Err(self.error("an XPath operator is missing")), + } + } + + fn finish_operand(&mut self, operand: Operand) -> JsResult { + match operand { + Operand { filter: Some((e, preds)), steps, .. } if preds.is_empty() && steps.is_empty() => Ok(e), + Operand { filter, absolute, steps, .. } => self.push_expr(Expr::Path { filter, absolute, steps }), + } + } + + fn reduce_one(&mut self, frame: &mut Frame) -> JsResult<()> { + let op = frame.ops.pop().ok_or_else(internal)?; + let rhs = frame.vals.pop().ok_or_else(internal)?; + let e = match op { + Op::Neg => Expr::Neg(rhs), + Op::Bin(b) => Expr::Bin(b, frame.vals.pop().ok_or_else(internal)?, rhs), + }; + let id = self.push_expr(e)?; + frame.vals.push(id); + Ok(()) + } + + fn binary(&mut self, b: Bin) -> JsResult<()> { + let mut frame = self.frames.pop().ok_or_else(internal)?; + let result = self.binary_in(&mut frame, b); + self.frames.push(frame); + result?; + self.want = Want::Operand; + Ok(()) + } + + fn binary_in(&mut self, frame: &mut Frame, b: Bin) -> JsResult<()> { + let operand = frame.operand.take().ok_or_else(internal)?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while frame.ops.last().is_some_and(|op| op.precedence() >= b.precedence()) { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + self.held.add(self.interp, 2 * size_of::())?; + frame.ops.push(Op::Bin(b)); + Ok(()) + } + + /// Ends the innermost bracket and returns its kind and expression. + fn close(&mut self) -> JsResult<(FrameKind, usize)> { + if self.frames.len() < 2 { + return Err(self.error("the XPath expression has an unmatched closing bracket")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame)?; + Ok((frame.kind, e)) + } + + fn reduce(&mut self, frame: &mut Frame) -> JsResult { + let operand = frame.operand.take().ok_or_else(|| syntax(self.interp, "an XPath operand is missing"))?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while !frame.ops.is_empty() { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + match frame.vals.as_slice() { + [e] => Ok(*e), + _ => Err(internal()), + } + } + + fn close_paren(&mut self) -> JsResult<()> { + let (kind, e) = if self.top()?.operand.is_none() { + (self.frames.pop().ok_or_else(internal)?.kind, None) + } else { + let (kind, e) = self.close()?; + (kind, Some(e)) + }; + match kind { + FrameKind::Group => self.primary_id(e.ok_or_else(internal)?), + FrameKind::Args(f, name, mut args) => { + args.extend(e); + let (_, _, least, most) = FUNCS.iter().find(|(_, g, ..)| *g == f).copied().ok_or_else(internal)?; + if args.len() < least || args.len() > most { + return Err(self.interp.type_error(&format!("the XPath function {name} takes the wrong number of arguments"))); + } + let call = self.push_expr(Expr::Call(f, args))?; + self.primary_id(call) + } + _ => Err(self.error("the XPath expression has an unmatched )")), + } + } + + fn primary_id(&mut self, id: usize) -> JsResult<()> { + if self.frames.is_empty() { + return Err(self.error("the XPath expression has an unmatched )")); + } + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn close_bracket(&mut self) -> JsResult<()> { + let (kind, e) = self.close()?; + if !matches!(kind, FrameKind::Pred) { + return Err(self.error("the XPath expression has an unmatched ]")); + } + let operand = self.operand()?; + match (operand.steps.last_mut(), operand.filter.as_mut()) { + (Some(step), _) => step.preds.push(e), + (None, Some((_, preds))) => preds.push(e), + (None, None) => return Err(internal()), + } + self.want = Want::After; + Ok(()) + } + + fn next_argument(&mut self) -> JsResult<()> { + if !matches!(self.top()?.kind, FrameKind::Args(..)) || self.frames.len() < 2 { + return Err(self.error("a comma in an XPath expression must separate function arguments")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame); + if let (Ok(e), FrameKind::Args(_, _, args)) = (&e, &mut frame.kind) { + args.push(*e); + } + frame.ops.clear(); + frame.vals.clear(); + self.frames.push(frame); + e?; + self.want = Want::Operand; + Ok(()) + } + + fn finish(mut self) -> JsResult { + let complete = self.want == Want::After || (self.want == Want::Step && self.operand()?.bare_root()); + if !complete || self.frames.len() != 1 { + return Err(self.error("the XPath expression ends early")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let root = self.reduce(&mut frame)?; + Ok(Parsed { exprs: self.exprs, root, vars: self.vars, prefixes: self.prefixes.into_iter().collect(), _held: self.held }) + } +} + +fn parse(interp: &mut Interp, src: &[u16]) -> JsResult { + let mut held = HeldBytes::new(interp); + let raw = lex(interp, &mut held, src)?; + let toks = classify(interp, &mut held, &raw)?; + drop(raw); + let mut p = Parser { + interp, + held, + exprs: Vec::new(), + vars: Vec::new(), + var_index: HashMap::new(), + prefixes: HashSet::new(), + frames: vec![Frame::new(FrameKind::Top)], + want: Want::Operand, + }; + for tok in toks { + p.interp.use_fuel()?; + p.token(tok)?; + } + p.finish() +} + +// ---- values ---- + +/// A node of the XPath data model. `Root` is the root above the topmost node it names, and `Ns` +/// is a namespace node of `owner`, at position `at` among its namespaces. +#[derive(Clone, PartialEq, Eq, Hash)] +enum XNode { + Root(ObjId), + Node(ObjId), + Ns { owner: ObjId, at: u32, prefix: Key, uri: Key }, +} + +#[derive(Clone, Copy, PartialEq)] +enum NodeKind { + Root, + Ns, + Xml(XmlKind), +} + +/// A list of nodes whose buffer is held against the heap limit. +struct Nodes { + list: Vec, + held: HeldBytes, +} + +impl Nodes { + fn new(interp: &Interp) -> Self { + Nodes { list: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, n: XNode) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.list, n) + } +} + +/// A string value whose units are held against the heap limit when the evaluator built them. +struct Text { + units: Key, + _held: HeldBytes, +} + +impl Text { + fn plain(interp: &Interp, units: Key) -> Self { + Text { units, _held: HeldBytes::new(interp) } + } +} + +struct TextBuf { + units: Vec, + held: HeldBytes, +} + +impl TextBuf { + fn new(interp: &Interp) -> Self { + TextBuf { units: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, more: &[u16]) -> JsResult<()> { + charge_units(interp, more.len())?; + let want = self.units.len().saturating_add(more.len()); + let cap = self.units.capacity(); + if want > cap { + let grown = want.max(cap.saturating_mul(2)).max(16); + self.held.add(interp, (grown - cap).saturating_mul(2))?; + self.units.reserve_exact(grown - self.units.len()); + } + self.units.extend_from_slice(more); + Ok(()) + } + + fn finish(self) -> Text { + Text { units: Rc::from(self.units), _held: self.held } + } +} + +enum Val { + Set(Nodes), + Bool(bool), + Num(f64), + Str(Text), +} + +/// A variable's value, read from the variables object before evaluation starts. +enum Bound { + Set(Vec), + Bool(bool), + Num(f64), + Str(Key), +} + +#[derive(Clone)] +struct Ctx { + node: XNode, + pos: usize, + size: usize, +} + +/// A location path being evaluated. Stage 0 applies the filter expression's predicates to the +/// filter's nodes; stage `s` applies step `s - 1` to each node of `inputs` in turn. +struct PathRun { + path: usize, + stage: usize, + inputs: Nodes, + ci: usize, + cur: Nodes, + pred: usize, + at: usize, + kept: Nodes, + waiting: bool, + out: Nodes, + seen: HashSet, + seen_held: HeldBytes, +} + +enum Task { + Eval(usize, Ctx), + Bin(Bin), + Neg, + Logic { and: bool, rhs: usize, ctx: Ctx }, + Bool, + Call(Func, usize, Ctx), + PathStart(usize), + Path(Box), +} + +fn to_bool(v: &Val) -> bool { + match v { + Val::Set(s) => !s.list.is_empty(), + Val::Bool(b) => *b, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(t) => !t.units.is_empty(), + } +} + +/// The number a string stands for (XPath 1.0 section 4.4): optional white space, an optional +/// minus, digits with an optional fraction, optional white space; anything else is NaN. +fn str_num(interp: &mut Interp, units: &[u16]) -> JsResult { + let start = units.iter().position(|&c| !is_xml_space(c)).unwrap_or(units.len()); + let end = units.iter().rposition(|&c| !is_xml_space(c)).map_or(start, |e| e + 1); + let body = units.get(start..end).unwrap_or_default(); + let (negative, digits) = match body.split_first() { + Some((&DASH, rest)) => (true, rest), + _ => (false, body), + }; + let dots = digits.iter().filter(|&&c| c == DOT).count(); + if dots > 1 || !digits.iter().all(|&c| c == DOT || is_digit(c)) || !digits.iter().any(|&c| is_digit(c)) { + return Ok(f64::NAN); + } + let mut held = HeldBytes::new(interp); + held.add(interp, digits.len())?; + let text: String = digits.iter().map(|&c| char::from(c as u8)).collect(); + let n: f64 = text.parse().unwrap_or(f64::NAN); + Ok(if negative { -n } else { n }) +} + +/// A number as a string (XPath 1.0 section 4.2): integers without a decimal point, and no +/// exponent. +fn num_text(n: f64) -> String { + if n.is_nan() { + "NaN".into() + } else if n.is_infinite() { + if n > 0.0 { "Infinity".into() } else { "-Infinity".into() } + } else if n == 0.0 { + "0".into() + } else { + format!("{n}") + } +} + +/// `round` (XPath 1.0 section 4.4): the nearest integer, halves toward positive infinity, with +/// negative zero for values from -0.5 up to zero. +fn round(n: f64) -> f64 { + if !n.is_finite() || n == 0.0 { + return n; + } + let floor = n.floor(); + let r = if n - floor >= 0.5 { floor + 1.0 } else { floor }; + if r == 0.0 && n < 0.0 { -0.0 } else { r } +} + +fn compare_numbers(op: Bin, a: f64, b: f64) -> bool { + match op { + Bin::Eq => a == b, + Bin::Ne => a != b, + Bin::Lt => a < b, + Bin::Le => a <= b, + Bin::Gt => a > b, + _ => a >= b, + } +} + +fn flip(op: Bin) -> Bin { + match op { + Bin::Lt => Bin::Gt, + Bin::Le => Bin::Ge, + Bin::Gt => Bin::Lt, + Bin::Ge => Bin::Le, + other => other, + } +} + +fn code_points(units: &[u16]) -> Vec { + char::decode_utf16(units.iter().copied()).map(|r| r.map_or_else(|e| u32::from(e.unpaired_surrogate()), u32::from)).collect() +} + +fn push_point(out: &mut Vec, p: u32) { + match char::from_u32(p) { + Some(c) => { + let mut buf = [0u16; 2]; + out.extend_from_slice(c.encode_utf16(&mut buf)); + } + None => out.extend(u16::try_from(p).ok()), + } +} + +fn lower(c: u16) -> u16 { + if (0x41..=0x5A).contains(&c) { c + 0x20 } else { c } +} + +fn same_ignoring_case(a: &[u16], b: &[u16]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(&x, &y)| lower(x) == lower(y)) +} + +fn name_uri(name: &XmlName) -> &[u16] { + name.uri.as_deref().unwrap_or_default() +} + +fn kind_of(interp: &Interp, n: &XNode) -> JsResult { + Ok(match n { + XNode::Root(_) => NodeKind::Root, + XNode::Ns { .. } => NodeKind::Ns, + XNode::Node(x) => NodeKind::Xml(interp.xml_kind(*x)?), + }) +} + +fn parent_of(interp: &Interp, n: &XNode) -> JsResult> { + Ok(match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(interp.xml_node(*x)?.parent.map_or(XNode::Root(*x), XNode::Node)), + XNode::Ns { owner, .. } => Some(XNode::Node(*owner)), + }) +} + +/// The parent of `x` and the position of `x` among its children, when `x` is a child. +fn child_position(interp: &mut Interp, x: ObjId) -> JsResult> { + let node = interp.xml_node(x)?; + let Some(p) = node.parent.filter(|_| node.kind != XmlKind::Attribute) else { return Ok(None) }; + let len = interp.xml_node(p)?.children.len(); + for i in 0..len { + interp.use_fuel()?; + if interp.xml_node(p)?.children.get(i) == Some(&x) { + return Ok(Some((p, i))); + } + } + Ok(None) +} + +fn top_of(interp: &mut Interp, mut x: ObjId) -> JsResult { + while let Some(p) = interp.xml_node(x)?.parent { + interp.use_fuel()?; + x = p; + } + Ok(x) +} + +/// The XPath expression evaluator for one call: the parsed expression, the variables, the +/// prefixes resolved for the current context element, and the document order of the trees met. +struct Ev<'p> { + parsed: &'p Parsed, + bound: Vec, + uris: HashMap, + order: HashMap, + root_order: HashMap, + next: u64, + held: HeldBytes, + tasks: Vec, + vals: Vec, +} + +impl<'p> Ev<'p> { + fn new(interp: &mut Interp, parsed: &'p Parsed, bound: Vec) -> JsResult { + let mut ev = Ev { + parsed, + bound: Vec::new(), + uris: HashMap::new(), + order: HashMap::new(), + root_order: HashMap::new(), + next: 0, + held: HeldBytes::new(interp), + tasks: Vec::new(), + vals: Vec::new(), + }; + for b in bound { + let b = match b { + Bound::Set(list) => { + let mut nodes = Nodes::new(interp); + for n in list { + nodes.push(interp, n)?; + } + let sorted = ev.sort(interp, nodes)?; + ev.held.add(interp, sorted.list.len().saturating_mul(size_of::()))?; + Bound::Set(sorted.list) + } + other => other, + }; + ev.bound.push(b); + } + Ok(ev) + } + + // ---- document order ---- + + fn put_order(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.held.add(interp, ENTRY_BYTES)?; + self.order.insert(x, self.next); + self.next += 1; + Ok(()) + } + + /// Numbers the tree under `top` in document order: the root, then each node followed by its + /// attributes and then its children. + fn number_tree(&mut self, interp: &mut Interp, top: ObjId) -> JsResult<()> { + if self.root_order.contains_key(&top) { + return Ok(()); + } + self.held.add(interp, ENTRY_BYTES)?; + self.root_order.insert(top, self.next); + self.next += 1; + self.number_node(interp, top)?; + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (top, 0usize))?; + while let Some(&(x, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(x)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.number_node(interp, c)?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn number_node(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.put_order(interp, x)?; + let count = interp.xml_node(x)?.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + if let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() { + self.put_order(interp, a)?; + } + } + Ok(()) + } + + fn order_of(&mut self, interp: &mut Interp, n: &XNode) -> JsResult<(u64, u32)> { + match n { + XNode::Root(t) => { + self.number_tree(interp, *t)?; + Ok((self.root_order.get(t).copied().ok_or_else(internal)?, 0)) + } + XNode::Node(x) => { + if !self.order.contains_key(x) { + let top = top_of(interp, *x)?; + self.number_tree(interp, top)?; + } + Ok((self.order.get(x).copied().ok_or_else(internal)?, 0)) + } + XNode::Ns { owner, at, .. } => { + let (o, _) = self.order_of(interp, &XNode::Node(*owner))?; + Ok((o, at.saturating_add(1))) + } + } + } + + /// The nodes in document order, without repeats. + fn sort(&mut self, interp: &mut Interp, set: Nodes) -> JsResult { + if set.list.len() < 2 { + return Ok(set); + } + let n = set.list.len() as u64; + let mut held = HeldBytes::new(interp); + let mut keyed = Vec::new(); + for x in set.list { + let k = self.order_of(interp, &x)?; + push_held(interp, &mut held, &mut keyed, (k, x))?; + } + use_fuel_n(interp, n.saturating_mul(u64::from(n.ilog2()) + 1) / 8 + 1)?; + keyed.sort_by_key(|(k, _)| *k); + keyed.dedup_by_key(|(k, _)| *k); + let mut out = Nodes::new(interp); + for (_, x) in keyed { + out.push(interp, x)?; + } + Ok(out) + } + + // ---- axes and node tests ---- + + fn prefix_uri(&self, p: &Key) -> JsResult<&Key> { + self.uris.get(p).ok_or_else(internal) + } + + fn matches(&self, interp: &Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult { + let kind = kind_of(interp, n)?; + let principal = match axis { + Axis::Attribute => NodeKind::Xml(XmlKind::Attribute), + Axis::Namespace => NodeKind::Ns, + _ => NodeKind::Xml(XmlKind::Element), + }; + let name = match n { + XNode::Node(x) => interp.xml_node(*x)?.name.as_ref(), + _ => None, + }; + Ok(match test { + Test::Node => true, + Test::Text => kind == NodeKind::Xml(XmlKind::Text), + Test::Comment => kind == NodeKind::Xml(XmlKind::Comment), + Test::Pi(target) => kind == NodeKind::Xml(XmlKind::Pi) && target.as_ref().is_none_or(|t| name.is_some_and(|q| q.local == *t)), + Test::Any => kind == principal, + Test::Prefix(p) => { + let uri = self.prefix_uri(p)?; + kind == principal && name.is_some_and(|q| name_uri(q) == &**uri) + } + Test::Name(p, local) => kind == principal && self.name_matches(n, name, p.as_ref(), local)?, + }) + } + + fn name_matches(&self, n: &XNode, name: Option<&XmlName>, prefix: Option<&Key>, local: &Key) -> JsResult { + if let XNode::Ns { prefix: ns_prefix, .. } = n { + return Ok(prefix.is_none() && ns_prefix == local); + } + let Some(q) = name else { return Ok(false) }; + let uri: &[u16] = match prefix { + Some(p) => self.prefix_uri(p)?, + None => &[], + }; + Ok(q.local == *local && name_uri(q) == uri) + } + + fn add(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: XNode) -> JsResult<()> { + interp.use_fuel()?; + if self.matches(interp, axis, test, &n)? { + out.push(interp, n)?; + } + Ok(()) + } + + /// Adds `x` and the nodes under it, in document order. + fn subtree(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + self.add(interp, out, axis, test, XNode::Node(x))?; + self.under(interp, out, axis, test, x) + } + + /// Adds the nodes under `x`, in document order. + fn under(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.add(interp, out, axis, test, XNode::Node(c))?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn descendants(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + match n { + XNode::Root(t) => self.subtree(interp, out, axis, test, *t), + XNode::Node(x) => self.under(interp, out, axis, test, *x), + XNode::Ns { .. } => Ok(()), + } + } + + fn children(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let x = match n { + XNode::Root(t) => return self.add(interp, out, axis, test, XNode::Node(*t)), + XNode::Node(x) => *x, + XNode::Ns { .. } => return Ok(()), + }; + let count = interp.xml_node(x)?.children.len(); + for i in 0..count { + if let Some(c) = interp.xml_node(x)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + fn ancestors(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let mut cur = parent_of(interp, n)?; + while let Some(p) = cur { + cur = parent_of(interp, &p)?; + self.add(interp, out, axis, test, p)?; + } + Ok(()) + } + + fn siblings(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + let Some((p, at)) = child_position(interp, *x)? else { return Ok(()) }; + let count = interp.xml_node(p)?.children.len(); + let range: Box> = if axis == Axis::FollowingSibling { Box::new(at + 1..count) } else { Box::new((0..at).rev()) }; + for i in range { + if let Some(c) = interp.xml_node(p)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + /// The node the following and preceding axes start from: an attribute's or a namespace + /// node's element, or the node itself. The flag is true for the first two. + fn start_of(interp: &Interp, n: &XNode) -> JsResult> { + Ok(match n { + XNode::Root(_) => None, + XNode::Ns { owner, .. } => Some((*owner, true)), + XNode::Node(x) => match interp.xml_node(*x)?.parent { + Some(p) if interp.xml_kind(*x)? == XmlKind::Attribute => Some((p, true)), + _ => Some((*x, false)), + }, + }) + } + + fn following(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, owned)) = Self::start_of(interp, n)? else { return Ok(()) }; + if owned { + self.under(interp, out, axis, test, cur)?; + } + while let Some((p, at)) = child_position(interp, cur)? { + let count = interp.xml_node(p)?.children.len(); + for i in at + 1..count { + if let Some(s) = interp.xml_node(p)?.children.get(i).copied() { + self.subtree(interp, out, axis, test, s)?; + } + } + cur = p; + } + Ok(()) + } + + fn preceding(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, _)) = Self::start_of(interp, n)? else { return Ok(()) }; + while let Some((p, at)) = child_position(interp, cur)? { + for i in (0..at).rev() { + let Some(s) = interp.xml_node(p)?.children.get(i).copied() else { continue }; + let mut part = Nodes::new(interp); + self.subtree(interp, &mut part, axis, test, s)?; + for x in part.list.into_iter().rev() { + out.push(interp, x)?; + } + } + cur = p; + } + Ok(()) + } + + fn attributes(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let count = interp.xml_node(*x)?.attrs.len(); + for i in 0..count { + if let Some(a) = interp.xml_node(*x)?.attrs.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(a))?; + } + } + Ok(()) + } + + fn namespaces(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let xml = key("xml"); + let mut at = 0u32; + let mut has_xml = false; + for ns in scope_of(interp, Some(*x))? { + let Some(prefix) = ns.prefix else { continue }; + if prefix.is_empty() && ns.uri.is_empty() { + continue; + } + has_xml |= prefix == xml; + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri })?; + at += 1; + } + if !has_xml { + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix: xml, uri: key(XML_URI) })?; + } + Ok(()) + } + + /// The nodes `axis` reaches from `n` that pass `test`, nearest first. + fn axis(&self, interp: &mut Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult { + let mut out = Nodes::new(interp); + match axis { + Axis::Itself => self.add(interp, &mut out, axis, test, n.clone())?, + Axis::Child => self.children(interp, &mut out, axis, test, n)?, + Axis::Descendant => self.descendants(interp, &mut out, axis, test, n)?, + Axis::DescendantOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.descendants(interp, &mut out, axis, test, n)?; + } + Axis::Parent => { + if let Some(p) = parent_of(interp, n)? { + self.add(interp, &mut out, axis, test, p)?; + } + } + Axis::Ancestor => self.ancestors(interp, &mut out, axis, test, n)?, + Axis::AncestorOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.ancestors(interp, &mut out, axis, test, n)?; + } + Axis::FollowingSibling | Axis::PrecedingSibling => self.siblings(interp, &mut out, axis, test, n)?, + Axis::Following => self.following(interp, &mut out, axis, test, n)?, + Axis::Preceding => self.preceding(interp, &mut out, axis, test, n)?, + Axis::Attribute => self.attributes(interp, &mut out, axis, test, n)?, + Axis::Namespace => self.namespaces(interp, &mut out, axis, test, n)?, + } + Ok(out) + } + + // ---- string values and conversions ---- + + /// The string-value of a node (XPath 1.0 section 5): the text under an element or the root, + /// the value of any other node, and a namespace node's URI. + fn string_value(&mut self, interp: &mut Interp, n: &XNode) -> JsResult { + let x = match n { + XNode::Ns { uri, .. } => return Ok(Text::plain(interp, uri.clone())), + XNode::Root(t) => *t, + XNode::Node(x) => *x, + }; + let kind = interp.xml_kind(x)?; + if kind == XmlKind::Element { + return self.text_under(interp, x); + } + if matches!(n, XNode::Root(_)) && kind != XmlKind::Text { + return Ok(Text::plain(interp, empty())); + } + let value = interp.xml_node(x)?.value.clone(); + let units = flat_charged(interp, &value)?; + charge_units(interp, units.len())?; + Ok(Text::plain(interp, units)) + } + + fn text_under(&mut self, interp: &mut Interp, x: ObjId) -> JsResult { + let mut buf = TextBuf::new(interp); + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + match interp.xml_kind(c)? { + XmlKind::Text => { + let value = interp.xml_node(c)?.value.clone(); + let units = flat_charged(interp, &value)?; + buf.push(interp, &units)?; + } + XmlKind::Element => push_held(interp, &mut held, &mut stack, (c, 0))?, + _ => {} + } + } + Ok(buf.finish()) + } + + fn text_of(&mut self, interp: &mut Interp, v: Val) -> JsResult { + match v { + Val::Set(s) => match s.list.first() { + Some(n) => self.string_value(interp, n), + None => Ok(Text::plain(interp, empty())), + }, + Val::Bool(b) => Ok(Text::plain(interp, key(if b { "true" } else { "false" }))), + Val::Num(n) => Ok(Text::plain(interp, key(&num_text(n)))), + Val::Str(t) => { + // The caller reads the units, so they take fuel each time a value is used. + charge_units(interp, t.units.len())?; + Ok(t) + } + } + } + + /// The number a node's string-value stands for. + fn node_number(&mut self, interp: &mut Interp, n: &XNode) -> JsResult { + let t = self.string_value(interp, n)?; + str_num(interp, &t.units) + } + + fn number_of(&mut self, interp: &mut Interp, v: Val) -> JsResult { + match v { + Val::Num(n) => Ok(n), + Val::Bool(b) => Ok(f64::from(u8::from(b))), + other => { + let t = self.text_of(interp, other)?; + str_num(interp, &t.units) + } + } + } + + // ---- comparisons and operators ---- + + fn compare(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + match (a, b) { + (Val::Set(x), Val::Set(y)) => self.compare_sets(interp, op, &x, &y), + (Val::Set(x), other) => self.compare_set(interp, op, &x, other), + (other, Val::Set(y)) => self.compare_set(interp, flip(op), &y, other), + (x, y) => self.compare_values(interp, op, x, y), + } + } + + fn compare_values(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + if !matches!(op, Bin::Eq | Bin::Ne) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } + let equal = if matches!(a, Val::Bool(_)) || matches!(b, Val::Bool(_)) { + to_bool(&a) == to_bool(&b) + } else if matches!(a, Val::Num(_)) || matches!(b, Val::Num(_)) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } else { + self.text_of(interp, a)?.units == self.text_of(interp, b)?.units + }; + Ok(equal == (op == Bin::Eq)) + } + + /// Compares each node of `set` with `other`, which is not a node-set; `set` is on the left. + fn compare_set(&mut self, interp: &mut Interp, op: Bin, set: &Nodes, other: Val) -> JsResult { + if let Val::Str(t) = &other { + charge_units(interp, t.units.len())?; + } + let n = match other { + Val::Bool(b) => return self.compare_values(interp, op, Val::Bool(!set.list.is_empty()), Val::Bool(b)), + Val::Str(t) if matches!(op, Bin::Eq | Bin::Ne) => { + for node in &set.list { + if (self.string_value(interp, node)?.units == t.units) == (op == Bin::Eq) { + return Ok(true); + } + } + return Ok(false); + } + Val::Str(t) => str_num(interp, &t.units)?, + Val::Num(n) => n, + Val::Set(_) => return Err(internal()), + }; + for node in &set.list { + let v = self.node_number(interp, node)?; + if compare_numbers(op, v, n) { + return Ok(true); + } + } + Ok(false) + } + + fn strings_of(&mut self, interp: &mut Interp, held: &mut HeldBytes, set: &Nodes) -> JsResult> { + let mut out = HashSet::new(); + for node in &set.list { + let t = self.string_value(interp, node)?; + held.add(interp, ENTRY_BYTES.saturating_add(t.units.len().saturating_mul(2)))?; + out.insert(t.units); + } + Ok(out) + } + + /// The smallest and largest numbers the nodes' string-values stand for, leaving out NaN. + fn number_range(&mut self, interp: &mut Interp, set: &Nodes) -> JsResult> { + let mut range: Option<(f64, f64)> = None; + for node in &set.list { + let v = self.node_number(interp, node)?; + if !v.is_nan() { + range = Some(range.map_or((v, v), |(lo, hi)| (lo.min(v), hi.max(v)))); + } + } + Ok(range) + } + + fn compare_sets(&mut self, interp: &mut Interp, op: Bin, a: &Nodes, b: &Nodes) -> JsResult { + if a.list.is_empty() || b.list.is_empty() { + return Ok(false); + } + if matches!(op, Bin::Eq | Bin::Ne) { + let mut held = HeldBytes::new(interp); + let right = self.strings_of(interp, &mut held, b)?; + for node in &a.list { + let s = self.string_value(interp, node)?; + let found = if op == Bin::Eq { right.contains(&s.units) } else { right.len() > 1 || !right.contains(&s.units) }; + if found { + return Ok(true); + } + } + return Ok(false); + } + let (Some((a_lo, a_hi)), Some((b_lo, b_hi))) = (self.number_range(interp, a)?, self.number_range(interp, b)?) else { return Ok(false) }; + Ok(match op { + Bin::Lt | Bin::Le => compare_numbers(op, a_lo, b_hi), + _ => compare_numbers(op, a_hi, b_lo), + }) + } + + fn binary(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + match op { + Bin::Union => { + let (Val::Set(mut x), Val::Set(y)) = (a, b) else { + return Err(interp.type_error("both sides of an XPath | must select nodes")); + }; + if x.list.is_empty() { + return Ok(Val::Set(y)); + } + for n in y.list { + x.push(interp, n)?; + } + Ok(Val::Set(self.sort(interp, x)?)) + } + Bin::Add | Bin::Sub | Bin::Mul | Bin::Div | Bin::Mod => { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + Ok(Val::Num(match op { + Bin::Add => x + y, + Bin::Sub => x - y, + Bin::Mul => x * y, + Bin::Div => x / y, + _ => x % y, + })) + } + Bin::Or | Bin::And => Err(internal()), + _ => Ok(Val::Bool(self.compare(interp, op, a, b)?)), + } + } + + // ---- functions ---- + + fn set_arg(interp: &mut Interp, v: Option, name: &str) -> JsResult { + match v { + Some(Val::Set(s)) => Ok(s), + _ => Err(interp.type_error(&format!("the XPath function {name} needs a node-set"))), + } + } + + fn text_arg(&mut self, interp: &mut Interp, v: Option, ctx: &Ctx) -> JsResult { + match v { + Some(v) => self.text_of(interp, v), + None => self.string_value(interp, &ctx.node), + } + } + + fn number_arg(&mut self, interp: &mut Interp, v: Option) -> JsResult { + let v = v.ok_or_else(internal)?; + self.number_of(interp, v) + } + + fn call(&mut self, interp: &mut Interp, f: Func, args: Vec, ctx: &Ctx) -> JsResult { + let mut args = args.into_iter(); + Ok(match f { + Func::Last => Val::Num(ctx.size as f64), + Func::Position => Val::Num(ctx.pos as f64), + Func::Count => Val::Num(Self::set_arg(interp, args.next(), "count")?.list.len() as f64), + Func::Id => Val::Set(Nodes::new(interp)), + Func::LocalName | Func::NamespaceUri | Func::Name => Val::Str(self.name_function(interp, f, args.next(), ctx)?), + Func::Sum => { + let set = Self::set_arg(interp, args.next(), "sum")?; + let mut total = 0.0; + for n in &set.list { + total += self.node_number(interp, n)?; + } + Val::Num(total) + } + Func::Boolean => Val::Bool(to_bool(&args.next().ok_or_else(internal)?)), + Func::Not => Val::Bool(!to_bool(&args.next().ok_or_else(internal)?)), + Func::True => Val::Bool(true), + Func::False => Val::Bool(false), + Func::Lang => { + let wanted = self.text_arg(interp, args.next(), ctx)?; + Val::Bool(self.lang(interp, &ctx.node, &wanted.units)?) + } + Func::Number => match args.next() { + Some(v) => Val::Num(self.number_of(interp, v)?), + None => Val::Num(self.node_number(interp, &ctx.node)?), + }, + Func::Floor => Val::Num(self.number_arg(interp, args.next())?.floor()), + Func::Ceiling => Val::Num(self.number_arg(interp, args.next())?.ceil()), + Func::Round => Val::Num(round(self.number_arg(interp, args.next())?)), + _ => return self.string_function(interp, f, args.collect(), ctx), + }) + } + + fn string_function(&mut self, interp: &mut Interp, f: Func, args: Vec, ctx: &Ctx) -> JsResult { + let mut texts = Vec::new(); + let mut held = HeldBytes::new(interp); + for v in args { + let t = self.text_of(interp, v)?; + push_held(interp, &mut held, &mut texts, t)?; + } + if texts.is_empty() && matches!(f, Func::String | Func::StringLength | Func::NormalizeSpace) { + texts.push(self.string_value(interp, &ctx.node)?); + } + if f == Func::String { + return Ok(Val::Str(texts.into_iter().next().ok_or_else(internal)?)); + } + let units = |at: usize| texts.get(at).map(|t| &*t.units).unwrap_or_default(); + Ok(match f { + Func::StringLength => Val::Num(char::decode_utf16(units(0).iter().copied()).count() as f64), + Func::StartsWith => Val::Bool(units(0).starts_with(units(1))), + Func::Contains => Val::Bool(find(interp, units(0), units(1))?.is_some()), + Func::SubstringBefore => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(..i)).unwrap_or_default())?) + } + Func::SubstringAfter => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(i + units(1).len()..)).unwrap_or_default())?) + } + Func::Concat => { + let mut buf = TextBuf::new(interp); + for t in &texts { + buf.push(interp, &t.units)?; + } + Val::Str(buf.finish()) + } + Func::NormalizeSpace => Val::Str(normalize_space(interp, units(0))?), + Func::Translate => Val::Str(translate(interp, units(0), units(1), units(2))?), + _ => return Err(internal()), + }) + } + + fn slice(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult { + let mut buf = TextBuf::new(interp); + buf.push(interp, units)?; + Ok(buf.finish()) + } + + fn substring(&mut self, interp: &mut Interp, args: Vec) -> JsResult { + let mut args = args.into_iter(); + let text = self.text_of(interp, args.next().ok_or_else(internal)?)?; + let start = round(self.number_arg(interp, args.next())?); + let end = match args.next() { + Some(len) => start + round(self.number_of(interp, len)?), + None => f64::INFINITY, + }; + charge_units(interp, text.units.len())?; + let mut held = HeldBytes::new(interp); + // The code points take 4 bytes each, and the result up to twice its 2-byte units. + held.add(interp, text.units.len().saturating_mul(8))?; + let mut out = Vec::new(); + for (i, p) in code_points(&text.units).into_iter().enumerate() { + let position = (i + 1) as f64; + if position >= start && position < end { + push_point(&mut out, p); + } + } + Ok(Val::Str(Text { units: Rc::from(out), _held: held })) + } + + fn name_function(&mut self, interp: &mut Interp, f: Func, arg: Option, ctx: &Ctx) -> JsResult { + let node = match arg { + None => Some(ctx.node.clone()), + Some(Val::Set(s)) => s.list.into_iter().next(), + Some(_) => return Err(interp.type_error("the XPath name functions need a node-set")), + }; + let units = match node { + None | Some(XNode::Root(_)) => empty(), + Some(XNode::Ns { prefix, .. }) => { + if f == Func::NamespaceUri { + empty() + } else { + prefix + } + } + Some(XNode::Node(x)) => node_name(interp, f, x)?, + }; + Ok(Text::plain(interp, units)) + } + + /// Whether the context node's language, from the nearest `xml:lang` attribute on it or an + /// ancestor, is `wanted` or a sublanguage of it, ignoring case. + fn lang(&mut self, interp: &mut Interp, n: &XNode, wanted: &[u16]) -> JsResult { + let mut cur = match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(*x), + XNode::Ns { owner, .. } => Some(*owner), + }; + let xml = key(XML_URI); + while let Some(x) = cur { + interp.use_fuel()?; + let node = interp.xml_node(x)?; + let parent = node.parent; + let count = node.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() else { break }; + let attr = interp.xml_node(a)?; + if attr.name.as_ref().is_some_and(|q| name_uri(q) == &*xml && units_are(&q.local, "lang")) { + let value = attr.value.clone(); + let value = flat_charged(interp, &value)?; + let head = value.get(..wanted.len()).unwrap_or_default(); + return Ok(same_ignoring_case(head, wanted) && (value.len() == wanted.len() || value.get(wanted.len()) == Some(&DASH))); + } + } + cur = parent; + } + Ok(false) + } + + // ---- running ---- + + fn task(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.tasks, t) + } + + fn val(&mut self, interp: &mut Interp, v: Val) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.vals, v) + } + + fn pop(&mut self) -> JsResult { + self.vals.pop().ok_or_else(internal) + } + + fn root_of(interp: &mut Interp, n: &XNode) -> JsResult { + Ok(match n { + XNode::Root(t) => XNode::Root(*t), + XNode::Node(x) => XNode::Root(top_of(interp, *x)?), + XNode::Ns { owner, .. } => XNode::Root(top_of(interp, *owner)?), + }) + } + + fn bound_value(&mut self, interp: &mut Interp, at: usize) -> JsResult { + Ok(match self.bound.get(at).ok_or_else(internal)? { + Bound::Bool(b) => Val::Bool(*b), + Bound::Num(n) => Val::Num(*n), + Bound::Str(s) => Val::Str(Text::plain(interp, s.clone())), + Bound::Set(list) => { + let list = list.clone(); + let mut nodes = Nodes::new(interp); + nodes.held.add(interp, list.len().saturating_mul(size_of::()))?; + nodes.list = list; + Val::Set(nodes) + } + }) + } + + fn eval(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult<()> { + let parsed = self.parsed; + match parsed.exprs.get(e).ok_or_else(internal)? { + Expr::Num(n) => self.val(interp, Val::Num(*n)), + Expr::Lit(s) => { + let t = Text::plain(interp, s.clone()); + self.val(interp, Val::Str(t)) + } + Expr::Var(at) => { + let v = self.bound_value(interp, *at)?; + self.val(interp, v) + } + Expr::Neg(a) => { + self.task(interp, Task::Neg)?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op @ (Bin::And | Bin::Or), a, b) => { + self.task(interp, Task::Logic { and: *op == Bin::And, rhs: *b, ctx: ctx.clone() })?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op, a, b) => { + self.task(interp, Task::Bin(*op))?; + self.task(interp, Task::Eval(*b, ctx.clone()))?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Call(f, args) => { + self.task(interp, Task::Call(*f, args.len(), ctx.clone()))?; + for a in args.iter().rev() { + self.task(interp, Task::Eval(*a, ctx.clone()))?; + } + Ok(()) + } + Expr::Path { filter: Some((primary, _)), .. } => { + self.task(interp, Task::PathStart(e))?; + self.task(interp, Task::Eval(*primary, ctx)) + } + Expr::Path { filter: None, absolute, steps } => { + let start = if *absolute { Self::root_of(interp, &ctx.node)? } else { ctx.node }; + let mut inputs = Nodes::new(interp); + inputs.push(interp, start)?; + if steps.is_empty() { + return self.val(interp, Val::Set(inputs)); + } + let first = self.step_axis(interp, e, 1, &inputs.list.first().cloned().ok_or_else(internal)?)?; + let run = self.new_run(interp, e, 1, inputs, first); + self.run_path(interp, Box::new(run)) + } + } + } + + fn new_run(&self, interp: &Interp, path: usize, stage: usize, inputs: Nodes, cur: Nodes) -> PathRun { + PathRun { + path, + stage, + inputs, + ci: 0, + cur, + pred: 0, + at: 0, + kept: Nodes::new(interp), + waiting: false, + out: Nodes::new(interp), + seen: HashSet::new(), + seen_held: HeldBytes::new(interp), + } + } + + fn step(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + match t { + Task::Eval(e, ctx) => self.eval(interp, e, ctx), + Task::Bin(op) => { + let b = self.pop()?; + let a = self.pop()?; + let v = self.binary(interp, op, a, b)?; + self.val(interp, v) + } + Task::Neg => { + let a = self.pop()?; + let n = self.number_of(interp, a)?; + self.val(interp, Val::Num(-n)) + } + Task::Logic { and, rhs, ctx } => { + let left = to_bool(&self.pop()?); + if left != and { + return self.val(interp, Val::Bool(left)); + } + self.task(interp, Task::Bool)?; + self.task(interp, Task::Eval(rhs, ctx)) + } + Task::Bool => { + let v = to_bool(&self.pop()?); + self.val(interp, Val::Bool(v)) + } + Task::Call(f, n, ctx) => { + let args = self.vals.split_off(self.vals.len().checked_sub(n).ok_or_else(internal)?); + let v = if f == Func::Substring { self.substring(interp, args)? } else { self.call(interp, f, args, &ctx)? }; + self.val(interp, v) + } + Task::PathStart(e) => { + let Val::Set(set) = self.pop()? else { + return Err(interp.type_error("an XPath predicate or step follows a value that is not a node-set")); + }; + let run = self.new_run(interp, e, 0, Nodes::new(interp), set); + self.run_path(interp, Box::new(run)) + } + Task::Path(run) => self.run_path(interp, run), + } + } + + /// The predicates of a path's stage, and its step for a stage after the filter's. + fn stage_of(&self, path: usize, stage: usize) -> JsResult<(&'p [usize], Option<&'p Step>)> { + let parsed = self.parsed; + let Some(Expr::Path { filter, steps, .. }) = parsed.exprs.get(path) else { return Err(internal()) }; + match stage.checked_sub(1) { + None => Ok((filter.as_ref().map(|(_, p)| p.as_slice()).unwrap_or_default(), None)), + Some(s) => { + let step = steps.get(s).ok_or_else(internal)?; + Ok((&step.preds, Some(step))) + } + } + } + + fn step_axis(&self, interp: &mut Interp, path: usize, stage: usize, n: &XNode) -> JsResult { + let (_, step) = self.stage_of(path, stage)?; + let step = step.ok_or_else(internal)?; + self.axis(interp, step.axis, &step.test, n) + } + + /// Runs a path until a predicate needs evaluating, which it schedules before itself, or until + /// the path's node-set is complete, which it pushes. + fn run_path(&mut self, interp: &mut Interp, mut r: Box) -> JsResult<()> { + loop { + interp.use_fuel()?; + if r.waiting { + r.waiting = false; + let v = self.pop()?; + let keep = match v { + Val::Num(n) => n == (r.at + 1) as f64, + other => to_bool(&other), + }; + if keep && let Some(n) = r.cur.list.get(r.at).cloned() { + r.kept.push(interp, n)?; + } + r.at += 1; + } + let (preds, _) = self.stage_of(r.path, r.stage)?; + if let Some(&pred) = preds.get(r.pred) { + if let Some(node) = r.cur.list.get(r.at).cloned() { + let ctx = Ctx { node, pos: r.at + 1, size: r.cur.list.len() }; + r.waiting = true; + self.task(interp, Task::Path(r))?; + return self.task(interp, Task::Eval(pred, ctx)); + } + r.cur = replace(&mut r.kept, Nodes::new(interp)); + r.pred += 1; + r.at = 0; + continue; + } + if !self.next_context(interp, &mut r)? { + return Ok(()); + } + } + } + + /// Adds the current context's nodes to the stage's result and moves to the next context or + /// stage. Returns false when the path is complete and its node-set has been pushed. + fn next_context(&mut self, interp: &mut Interp, r: &mut PathRun) -> JsResult { + let cur = replace(&mut r.cur, Nodes::new(interp)); + if r.inputs.list.len() > 1 { + for n in cur.list { + if !r.seen.contains(&n) { + r.seen_held.add(interp, ENTRY_BYTES)?; + r.seen.insert(n.clone()); + r.out.push(interp, n)?; + } + } + } else { + r.out = cur; + } + r.ci += 1; + if let Some(n) = r.inputs.list.get(r.ci).cloned() { + r.cur = self.step_axis(interp, r.path, r.stage, &n)?; + r.pred = 0; + r.at = 0; + return Ok(true); + } + let out = replace(&mut r.out, Nodes::new(interp)); + let (_, step) = self.stage_of(r.path, r.stage)?; + let done = if r.inputs.list.len() > 1 { + self.sort(interp, out)? + } else if step.is_some_and(|s| s.axis.reverse()) { + let mut out = out; + out.list.reverse(); + out + } else { + out + }; + r.seen.clear(); + r.seen_held = HeldBytes::new(interp); + r.stage += 1; + let more = self.stage_of(r.path, r.stage).is_ok(); + let Some(first) = done.list.first().cloned().filter(|_| more) else { + self.val(interp, Val::Set(done))?; + return Ok(false); + }; + r.inputs = done; + r.ci = 0; + r.cur = self.step_axis(interp, r.path, r.stage, &first)?; + r.pred = 0; + r.at = 0; + Ok(true) + } + + fn run(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult { + self.tasks.clear(); + self.vals.clear(); + self.task(interp, Task::Eval(e, ctx))?; + while let Some(t) = self.tasks.pop() { + interp.use_fuel()?; + self.step(interp, t)?; + } + let v = self.pop()?; + if !self.vals.is_empty() { + return Err(internal()); + } + Ok(v) + } + + /// Binds the expression's prefixes to the namespaces in scope on `x`. + fn resolve_prefixes(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.uris.clear(); + if self.parsed.prefixes.is_empty() { + return Ok(()); + } + let scope = scope_of(interp, Some(x))?; + let parsed = self.parsed; + for p in &parsed.prefixes { + let uri = if units_are(p, "xml") { + key(XML_URI) + } else { + match scope.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { + Some(ns) if !ns.uri.is_empty() => ns.uri.clone(), + _ => { + let shown = String::from_utf16_lossy(p); + return Err(interp.type_error(&format!("the XPath prefix {shown} is not bound to a namespace"))); + } + } + }; + self.held.add(interp, ENTRY_BYTES)?; + self.uris.insert(p.clone(), uri); + } + Ok(()) + } + + /// The nodes the expression selects with `x` as the context node. + fn select(&mut self, interp: &mut Interp, x: ObjId) -> JsResult> { + self.resolve_prefixes(interp, x)?; + let root = self.parsed.root; + let Val::Set(set) = self.run(interp, root, Ctx { node: XNode::Node(x), pos: 1, size: 1 })? else { + return Err(interp.type_error("the XPath expression does not evaluate to a node-set")); + }; + let mut out = Vec::new(); + let mut held = HeldBytes::new(interp); + for n in set.list { + match n { + XNode::Node(id) => push_held(interp, &mut held, &mut out, id)?, + XNode::Root(_) => return Err(interp.type_error("the XPath expression selects the root node, which is not an XML value")), + XNode::Ns { .. } => return Err(interp.type_error("the XPath expression selects a namespace node, which is not an XML value")), + } + } + Ok(out) + } +} + +fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { + let node = interp.xml_node(x)?; + let (kind, parent) = (node.kind, node.parent); + let Some(name) = node.name.clone() else { return Ok(empty()) }; + match (f, kind) { + (_, XmlKind::Text | XmlKind::Comment) => Ok(empty()), + (Func::NamespaceUri, XmlKind::Pi) => Ok(empty()), + (Func::NamespaceUri, _) => Ok(Rc::from(name_uri(&name))), + (Func::LocalName, _) | (_, XmlKind::Pi) => Ok(name.local), + _ => qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), + } +} + +/// An element's or attribute's name as a QName, with a prefix declared for its namespace. +fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { + let uri = name_uri(name); + if uri.is_empty() { + return Ok(name.local.clone()); + } + let prefix = if units_are(uri, XML_URI) { + key("xml") + } else { + match &name.prefix { + Some(p) if !(attribute && p.is_empty()) => p.clone(), + _ => { + let scope = scope_of(interp, if attribute { parent } else { Some(x) })?; + let usable = |ns: &XmlNs| *ns.uri == *uri && ns.prefix.as_ref().is_some_and(|p| !(attribute && p.is_empty())); + scope.into_iter().find(usable).and_then(|ns| ns.prefix).unwrap_or_else(empty) + } + } + }; + if prefix.is_empty() { + return Ok(name.local.clone()); + } + let mut units = prefix.to_vec(); + units.push(COLON); + units.extend_from_slice(&name.local); + Ok(Rc::from(units)) +} + +/// The first index where `needle` occurs in `hay`. Every position tried takes fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16]) -> JsResult> { + string_builtins::find(interp, hay, needle, 0) +} + +fn normalize_space(interp: &mut Interp, units: &[u16]) -> JsResult { + let mut buf = TextBuf::new(interp); + for word in units.split(|&c| is_xml_space(c)).filter(|w| !w.is_empty()) { + if !buf.units.is_empty() { + buf.push(interp, &[0x20])?; + } + buf.push(interp, word)?; + } + Ok(buf.finish()) +} + +fn translate(interp: &mut Interp, units: &[u16], from: &[u16], to: &[u16]) -> JsResult { + charge_units(interp, units.len() + from.len() + to.len())?; + let mut held = HeldBytes::new(interp); + held.add(interp, from.len().saturating_add(units.len()).saturating_add(to.len()).saturating_mul(ENTRY_BYTES))?; + let to = code_points(to); + let mut map: HashMap> = HashMap::new(); + for (i, p) in code_points(from).into_iter().enumerate() { + map.entry(p).or_insert_with(|| to.get(i).copied()); + } + let mut out = Vec::new(); + for p in code_points(units) { + match map.get(&p) { + Some(Some(t)) => push_point(&mut out, *t), + Some(None) => {} + None => push_point(&mut out, p), + } + } + Ok(Text { units: Rc::from(out), _held: held }) +} + +/// Reads the variables the expression names from `variables`. +fn bind(interp: &mut Interp, held: &mut HeldBytes, parsed: &Parsed, variables: &Value) -> JsResult> { + let mut out = Vec::new(); + for name in &parsed.vars { + let v = match variables { + Value::Object(o) if interp.has_property(*o, name)? => interp.get_from(*o, name, variables)?, + _ => Value::Undefined, + }; + if matches!(v, Value::Undefined) { + let shown = String::from_utf16_lossy(name); + return Err(interp.type_error(&format!("the XPath variable ${shown} is not bound"))); + } + interp.temp_roots.push(v.clone()); + let b = match (interp.classify(&v), &v) { + (Xv::Node(n), _) => Bound::Set(vec![XNode::Node(n)]), + (Xv::List(l), _) => { + // A later getter may change the list, so its items are held as roots themselves. + let count = interp.xml_list(l)?.items.len(); + held.add(interp, count.saturating_mul(size_of::() + size_of::() + size_of::()))?; + let items = interp.xml_list(l)?.items.clone(); + interp.temp_roots.extend(items.iter().map(|&n| Value::Object(n))); + Bound::Set(items.into_iter().map(XNode::Node).collect()) + } + (Xv::Other, Value::Bool(b)) => Bound::Bool(*b), + (Xv::Other, Value::Number(n)) => Bound::Num(*n), + (Xv::Other, _) => { + let s = interp.to_str(&v)?; + Bound::Str(flat_charged(interp, &s)?) + } + }; + push_held(interp, held, &mut out, b)?; + } + Ok(out) +} + +/// The nodes an expression runs on: the XML value itself, or each element of an XMLList. +fn contexts(interp: &mut Interp, held: &mut HeldBytes, this: &Value) -> JsResult> { + match interp.classify(this) { + Xv::Node(n) => Ok(vec![n]), + Xv::List(l) => { + let count = interp.xml_list(l)?.items.len(); + let mut out = Vec::new(); + for i in 0..count { + interp.use_fuel()?; + let Some(item) = interp.xml_list(l)?.items.get(i).copied() else { break }; + if interp.xml_kind(item)? == XmlKind::Element { + push_held(interp, held, &mut out, item)?; + } + } + Ok(out) + } + Xv::Other => Err(interp.type_error("the method needs an XML value")), + } +} + +/// `xpath(expression[, variables])`: the nodes the XPath 1.0 `expression` selects with this XML +/// value as the context node, or, for an XMLList, with each of its elements in turn, as one +/// XMLList. Each property of `variables` binds the variable of its name. +pub(crate) fn xpath(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + if matches!(interp.classify(&this), Xv::Other) { + return Err(interp.type_error("the method needs an XML value")); + } + let Value::String(source) = arg(args, 0) else { + return Err(interp.type_error("xpath needs the expression as a string")); + }; + let variables = arg(args, 1); + if !matches!(variables, Value::Undefined | Value::Object(_)) { + return Err(interp.type_error("xpath needs its variables as an object")); + } + interp.rooted(&[this.clone(), variables.clone()], |i| { + let units = flat_charged(i, &source)?; + let parsed = parse(i, &units)?; + let mut held = HeldBytes::new(i); + let bound = bind(i, &mut held, &parsed, &variables)?; + let items = contexts(i, &mut held, &this)?; + let out = i.new_xml_list(None, None)?; + i.xml_keep(out); + let mut ev = Ev::new(i, &parsed, bound)?; + for item in items { + for id in ev.select(i, item)? { + i.list_push(out, id)?; + } + } + Ok(Value::Object(out)) + }) +} diff --git a/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/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 00ff3a2d77..54baeeb0c3 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -77,6 +77,7 @@ fn export_tool_lists_engine_formats_and_options() { "eps", "emf", "wmf", + "hpgl", "tga", "psd" ]) diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 17c7ecc912..d4d69c945f 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"), @@ -393,14 +393,14 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf, hpgl pen moves for plotters and cutters), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, "path": string("Destination file (omit to get dataBase64)"), "scale": num("Raster pixels per point (without scale or options.ppi, uses the document's raster effects resolution; options.ppi wins)"), "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"}, - "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), + "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF, WMF and HP-GL: one, or one file each with options.useArtboards: true)"), "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, "outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"}, "options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"}, diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c6cf12857f..b07964b225 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -634,6 +634,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", ), + ( + "window.learn", + "Learn", + "", + "{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows", + ), + ( + "learn.list", + "List Tutorials", + "", + "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + ), + ( + "learn.show", + "Show Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", @@ -748,6 +766,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", @@ -1486,7 +1510,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}", ), ( "file.print", @@ -1523,7 +1547,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/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 5326e2f307..a517d516a6 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title), + Kind::Emf => emf::write(&ops, r, doc.info_title()), Kind::Wmf => wmf::write(&ops, r, &mut warnings), }; Ok(Output { bytes, warnings }) diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 5a19e62564..788cacea3a 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -53,7 +53,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let raised = for_long_pages(set, &pages)?; let set = raised.as_ref().unwrap_or(set); - let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); + // The file's Title is File Info's; the page information names the document, as its tab does. + let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned())); + let name = opts.title.as_deref().unwrap_or(&doc.title); // The time of writing (none on the web) dates the editing data and the page information. let now = vectorcraft_doc::metadata::output_time(); let created_at = opts.created.or(now); @@ -108,7 +110,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); let (m, r) = (boxes.media, boxes.bleed); - let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; + let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() }; let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); // The page is the media box, y down from its top-left corner (like ours), in units of // `unit` points. diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 983a7dcea7..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { + /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`. + pub title: Option, /// XMP `dc:creator` (its names joined with ", "), else Info `Author`. pub author: Option, /// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`. @@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default(); let info = pdf.metadata(); let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + f.title = f.title.or_else(|| text(&info.title)); f.author = f.author.or_else(|| text(&info.author)); f.description = f.description.or_else(|| text(&info.subject)); f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty())); @@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option { let creators = get(DC, "creator"); let subjects = get(DC, "subject"); Some(FileInfo { + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), description: first(DC, "description"), keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, diff --git a/crates/pdf/src/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/file_info.rs b/crates/pdf/src/tests/file_info.rs index 7ce2f42f47..4c837ba67f 100644 --- a/crates/pdf/src/tests/file_info.rs +++ b/crates/pdf/src/tests/file_info.rs @@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String { } const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \ - /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)"; + /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)"; /// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00). const CREATED: i64 = 1_791_201_780; @@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Compiler manual".into()), author: Some("Grace Hopper".into()), description: Some("Cata log".into()), keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]), @@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() { fn the_xmp_packet_gives_file_info() { let body = description( "xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"", - "Ada LovelaceCharles Babbage\ + "PlakatPoster\ + Ada LovelaceCharles Babbage\ Ein PlakatA poster\nfor the fair\ fair print \ (c) 2026 Ada\ @@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Poster".into()), author: Some("Ada Lovelace, Charles Babbage".into()), description: Some("A poster\nfor the fair".into()), keywords: Some(vec!["fair".into(), "print".into()]), @@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() { assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60)); assert_eq!(f.description.as_deref(), Some("Cata log")); assert_eq!(f.modified, Some(MODIFIED)); + // The title is the packet's where it gives one (a blank `x-default` item leaves the next one), + // else the dictionary's. + assert_eq!(f.title.as_deref(), Some("Compiler manual")); + let body = + description("", " Manuel"); + let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Manuel")); } #[test] @@ -197,17 +207,29 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() { #[test] fn a_written_pdf_gives_its_file_info_back() { let mut d = Document::new(100.0, 100.0); + d.title = "Fair poster".into(); d.metadata.author = "Ada Lovelace".into(); d.metadata.description = "A poster\nfor the fair".into(); d.metadata.keywords = vec!["fair".into(), "print".into()]; let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); let f = file_info(&bytes, None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Fair poster")); assert_eq!(f.author.as_deref(), Some("Ada Lovelace")); assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair")); assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } +#[test] +fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() { + let mut d = Document::new(100.0, 100.0); + d.title = "poster.svg".into(); + d.metadata.title = Some("Fair poster".into()); + let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); + assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster")); + assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg")); +} + #[test] fn a_written_pdf_gives_its_copyright_back() { use vectorcraft_doc::CopyrightStatus; diff --git a/crates/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/deferred.rs b/crates/plugins/src/deferred.rs index 1645dd55e0..a29d877d44 100644 --- a/crates/plugins/src/deferred.rs +++ b/crates/plugins/src/deferred.rs @@ -19,7 +19,7 @@ //! [`revision`] changes, so that drawings made with the deferred stage are drawn again. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -106,9 +106,108 @@ enum Phase { Open, } +/// The deferred records in the order they were made, indexed by stage and cache key. A stage holds +/// at most one record for each key. +#[derive(Default)] +struct Records { + /// The order number of the next record. + next: u64, + by_order: BTreeMap, + /// For each stage with records, the order number of its record for each key. + by_stage: HashMap>, +} + +impl Records { + /// Every record, oldest first. + fn iter(&self) -> impl Iterator { + self.by_order.values().inspect(|_| looked_at(1)) + } + + /// The stages with records, in no particular order. + fn stages(&self) -> impl Iterator + '_ { + self.by_stage.keys().copied() + } + + /// The records of `stage`, in no particular order. + fn of(&self, stage: Stage) -> impl Iterator { + self.by_stage.get(&stage).into_iter().flat_map(|keys| keys.values()).filter_map(|n| { + looked_at(1); + self.by_order.get(n) + }) + } + + /// Is `stage` recorded with cache key `key`? + fn contains(&self, stage: Stage, key: u64) -> bool { + looked_at(1); + self.by_stage.get(&stage).is_some_and(|keys| keys.contains_key(&key)) + } + + /// Adds `d` as the newest record, in place of the record of its stage and key. + fn push(&mut self, d: Deferred) { + let order = self.next; + self.next = self.next.wrapping_add(1); + if let Some(old) = self.by_stage.entry(d.stage).or_default().insert(d.key, order) + && let Some(old) = self.by_order.remove(&old) + { + unmark(old.key); + } + self.by_order.insert(order, d); + } + + /// Drops the records of `stage` whose key `drop` gives true for. + fn drop_where(&mut self, stage: Stage, drop: impl Fn(u64) -> bool) { + let Some(keys) = self.by_stage.get_mut(&stage) else { return }; + looked_at(keys.len()); + let by_order = &mut self.by_order; + keys.retain(|&key, order| { + let keep = !drop(key); + if !keep && let Some(d) = by_order.remove(order) { + unmark(d.key); + } + keep + }); + if keys.is_empty() { + self.by_stage.remove(&stage); + } + } + + /// Drops the records for which `keep` is false. + fn retain(&mut self, mut keep: impl FnMut(&Deferred) -> bool) { + let by_stage = &mut self.by_stage; + self.by_order.retain(|_, d| { + looked_at(1); + let k = keep(d); + if !k { + if let Some(keys) = by_stage.get_mut(&d.stage) { + keys.remove(&d.key); + if keys.is_empty() { + by_stage.remove(&d.stage); + } + } + unmark(d.key); + } + k + }); + } +} + +#[cfg(test)] +thread_local! { + /// How many records the lookups on this thread have looked at. + static LOOKED_AT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` records looked at, in tests. +fn looked_at(n: usize) { + #[cfg(test)] + LOOKED_AT.with(|c| c.set(c.get() + n)); + #[cfg(not(test))] + let _ = n; +} + struct State { phase: Phase, - deferred: Vec, + deferred: Records, /// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it. report: Option, /// The refresh running, if one is ([`Deferrals::refresh`]). @@ -123,13 +222,13 @@ struct Refresh { /// When its budget started (`None`: no run yet). started: Option, /// The objects whose stages run again; the others' stay deferred. - ids: Vec, + ids: HashSet, /// The cache keys whose cached failures have been run again. - retried: Vec, + retried: HashSet, /// The stages of `ids` that were drawn. - visited: Vec, + visited: HashSet, /// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again. - touched: Vec<(Stage, u64)>, + touched: HashSet<(Stage, u64)>, } impl State { @@ -155,7 +254,7 @@ impl State { impl Drop for State { fn drop(&mut self) { - for d in &self.deferred { + for d in self.deferred.iter() { unmark(d.key); } } @@ -197,7 +296,7 @@ impl Deferrals { pub fn with_clock(clock: Clock) -> Self { Self(Arc::new(Mutex::new(State { phase: Phase::Budgeted { started: None, first: true }, - deferred: Vec::new(), + deferred: Records::default(), report: None, refresh: None, clock, @@ -211,7 +310,7 @@ impl Deferrals { /// The deferred stages, by object and effect, each once (a stage drawn with several inputs, /// such as the paths of a group, is listed with the first one's reason). pub fn list(&self) -> Vec { - let mut out = self.state().deferred.clone(); + let mut out: Vec = self.state().deferred.iter().cloned().collect(); out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect)); out.dedup_by_key(|d| d.stage); out @@ -238,13 +337,10 @@ impl Deferrals { if pass.ran.is_empty() { return; } - self.state().deferred.retain(|d| { - let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key)); - if !keep { - unmark(d.key); - } - keep - }); + let mut s = self.state(); + for &stage in &pass.ran { + s.deferred.drop_where(stage, |key| !pass.seen.contains(&(stage, key))); + } } /// The first view has been drawn: later drawings leave deferred stages deferred, and @@ -271,14 +367,14 @@ impl Deferrals { pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec) -> Refreshed { let (targets, ids) = { let mut s = self.state(); - let mut targets: Vec = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect(); + let asked: Option> = objects.map(|o| o.iter().copied().collect()); + let mut targets: Vec = s.deferred.stages().filter(|t| asked.as_ref().is_none_or(|o| o.contains(&t.object))).collect(); targets.sort_by_key(|t| (t.object, t.item, t.effect)); - targets.dedup(); let mut ids: Vec = targets.iter().map(|t| t.object).collect(); ids.dedup(); let thread = std::thread::current().id(); - let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new()); - s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched }); + let (retried, visited, touched) = (HashSet::new(), HashSet::new(), HashSet::new()); + s.refresh = Some(Refresh { thread, started: None, ids: ids.iter().copied().collect(), retried, visited, touched }); (targets, ids) }; // The refresh ends when `run` returns, also when it unwinds. @@ -289,20 +385,16 @@ impl Deferrals { } } let reopen = Reopen(self); - let whole = run(&ids); + let whole: HashSet = run(&ids).into_iter().collect(); let mut s = self.state(); let touched = match s.refresh.take() { Some(r) => { - s.deferred.retain(|d| { - let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key)); - if gone { - unmark(d.key); - } - !gone - }); + for &stage in r.visited.iter().filter(|t| whole.contains(&t.object)) { + s.deferred.drop_where(stage, |key| !r.touched.contains(&(stage, key))); + } r.touched } - None => Vec::new(), + None => HashSet::new(), }; drop(s); drop(reopen); @@ -310,11 +402,11 @@ impl Deferrals { // than time; one with records left that this refresh did not run is still deferred. let s = self.state(); let mut out = Refreshed::default(); - for t in &targets { - let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable(); + for &t in &targets { + let mut left = s.deferred.of(t).peekable(); if left.peek().is_none() { out.refreshed += 1; - } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) { + } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(t, d.key))) { out.failed += 1; } else { out.still_deferred += 1; @@ -355,7 +447,7 @@ impl Deferrals { pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool { let mut s = self.state(); let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false); - let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key); + let held = settled && s.deferred.contains(stage, key); drop(s); if held { seen(stage, key); @@ -367,30 +459,22 @@ impl Deferrals { /// reached the stage. pub(crate) fn visit(&self, stage: Stage) { let mut s = self.state(); - if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) { - r.visited.push(stage); + if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object)) { + r.visited.insert(stage); } } /// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is /// false, such as those of effects or objects removed from the document. pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) { - self.state().deferred.retain(|d| { - let k = keep(d.stage, &d.plugin); - if !k { - unmark(d.key); - } - k - }); + self.state().deferred.retain(|d| keep(d.stage, &d.plugin)); } /// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key /// while a refresh running here runs the stages of `stage`'s object. pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool { let mut s = self.state(); - let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false }; - r.retried.push(key); - true + s.refresh_here().filter(|r| r.ids.contains(&stage.object)).is_some_and(|r| r.retried.insert(key)) } /// What a run starting now may take of the budget: the budget starts with the first run. @@ -425,13 +509,7 @@ impl State { /// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0 /// removes every record of the stage. fn remove(&mut self, stage: Stage, key: u64) { - self.deferred.retain(|d| { - let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0); - if !keep { - unmark(d.key); - } - keep - }); + self.deferred.drop_where(stage, |k| k == key || k == 0 || key == 0); } /// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this @@ -439,12 +517,12 @@ impl State { /// view [`Deferrals::settle`] looks at the stage when that drawing ends. fn ran_here(&mut self, stage: Stage, key: u64) { if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) { - r.touched.push((stage, key)); + r.touched.insert((stage, key)); } if self.phase_here() == Phase::Open { let _ = PASSES.try_with(|p| { - if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) { - pass.ran.push(stage); + if let Some(pass) = p.borrow_mut().last_mut() { + pass.ran.insert(stage); } }); } @@ -452,9 +530,8 @@ impl State { /// The deferred stages, each once. fn stages(&self) -> Vec { - let mut out: Vec = self.deferred.iter().map(|d| d.stage).collect(); + let mut out: Vec = self.deferred.stages().collect(); out.sort_by_key(|t| (t.object, t.item, t.effect)); - out.dedup(); out } } @@ -469,15 +546,15 @@ thread_local! { /// deferred again), and the stage inputs it left deferred. #[derive(Default)] struct Pass { - ran: Vec, - seen: Vec<(Stage, u64)>, + ran: HashSet, + seen: HashSet<(Stage, u64)>, } /// The drawing on this thread left `stage` deferred with cache key `key`. fn seen(stage: Stage, key: u64) { let _ = PASSES.try_with(|p| { if let Some(pass) = p.borrow_mut().last_mut() { - pass.seen.push((stage, key)); + pass.seen.insert((stage, key)); } }); } @@ -676,6 +753,79 @@ mod tests { assert_eq!(d.list().iter().map(|x| x.stage).collect::>(), [st(2, 0), st(3, 0)]); } + /// Objects asked for or drawn whole more than once count once; a stage with several inputs + /// gives a result when its object is drawn whole, fails when an input it ran with fails again + /// (even beside an older failure it did not run), and stays deferred when it is reached but + /// not drawn whole or not reached at all. + #[test] + fn a_refresh_with_repeated_objects_and_several_inputs_per_stage() { + const K: u64 = 0xdef1_0000; + let marked = |key| MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key)); + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let st = |object| Stage { object, effect: 0, item: None }; + for (object, reason, key) in [ + (10, Reason::Budget, K + 1), + (10, Reason::Budget, K + 2), + (11, Reason::Timeout, K + 3), + (11, Reason::Budget, K + 4), + (12, Reason::Timeout, K + 5), + (13, Reason::Budget, K + 6), + (14, Reason::Budget, K + 7), + ] { + d.defer(st(object), "a", reason, key); + } + let before = revision(); + let seen = std::cell::RefCell::new(vec![]); + let counts = d.refresh(Some(&[10, 11, 12, 13, 10, 11, 99]), |ids| { + seen.borrow_mut().extend_from_slice(ids); + d.visit(st(10)); + d.visit(st(10)); + d.ran(st(10), K + 1); + d.visit(st(11)); + d.defer(st(11), "a", Reason::Failed, K + 8); + d.visit(st(12)); + vec![10, 13, 10, 13] + }); + assert_eq!(*seen.borrow(), [10, 11, 12, 13]); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 1 }); + assert!(revision() > before); + let keys = || d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::>(); + assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]); + assert!(!marked(K + 1) && !marked(K + 2), "the records of the stage that gave a result"); + assert!(marked(K + 3) && marked(K + 8)); + + // Again, with nothing reached: every stage asked for stays as it was. + let counts = d.refresh(Some(&[11, 12, 12]), |_| vec![11, 12]); + assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 2, failed: 0 }); + assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]); + } + + /// A refresh whose drawing unwinds ends: later drawings on this thread are no longer part of + /// it, and the next refresh has a budget of its own. + #[test] + fn a_refresh_that_unwinds_ends() { + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let s = Stage { object: 5, effect: 0, item: None }; + d.defer(s, "a", Reason::Budget, 0xdef2_0001); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + d.refresh(None, |_| { + assert!(!d.holds(s, 0xdef2_0001)); + panic!("the drawing stopped") + }) + })); + assert!(unwound.is_err()); + assert!(matches!(d.allowance(), Allowance::Unlimited)); + assert!(d.holds(s, 0xdef2_0001), "the stage stays deferred"); + let counts = d.refresh(Some(&[5]), |_| { + assert!(matches!(d.allowance(), Allowance::Until { .. })); + d.visit(s); + vec![5] + }); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 0, failed: 0 }); + } + /// After the first view, a stage drawn with several inputs (the paths of a group) whose one /// changed input runs keeps the records of the inputs the same drawing left deferred, and drops /// the record of the input it no longer has. @@ -724,4 +874,62 @@ mod tests { drop((d, other)); assert!(!marked(), "the document closed"); } + + /// Deferrals after the first view with `n` stages, one input each, keyed from `base`. + fn many(n: u64, base: u64) -> Deferrals { + let d = Deferrals::with_clock(stepping(1)); + for object in 0..n { + d.defer(Stage { object, effect: 0, item: None }, "a", Reason::Budget, base + object); + } + d.first_view_done(); + d + } + + /// How many records `f` looks at on this thread. + fn looked_at_by(f: impl FnOnce()) -> usize { + let before = LOOKED_AT.with(std::cell::Cell::get); + f(); + LOOKED_AT.with(std::cell::Cell::get) - before + } + + /// Asking whether a stage input stays deferred looks at that stage's records only, however + /// many other stages are deferred. + #[test] + fn holding_a_stage_looks_at_its_own_records_only() { + const K: u64 = 0xdef3_0000; + let d = many(2_000, K); + let s = Stage { object: 1_000, effect: 0, item: None }; + let n = looked_at_by(|| { + assert!(d.holds(s, K + 1_000)); + assert!(!d.holds(s, K + 1_001), "another stage's input"); + assert!(!d.holds(Stage { effect: 1, ..s }, K + 1_000), "another stage"); + }); + assert!(n <= 3, "looked at {n} records"); + } + + /// A stage that gives a result, or is deferred again, replaces its own records only, and so + /// does the end of a drawing after the first view: the other stages' records are not looked at. + #[test] + fn replacing_a_stage_record_looks_at_its_own_records_only() { + const K: u64 = 0xdef4_0000; + let d = many(2_000, K); + let st = |object| Stage { object, effect: 0, item: None }; + let n = looked_at_by(|| { + d.ran(st(1_000), K + 1_000); + d.defer(st(1_001), "a", Reason::Failed, K + 9_999); + d.defer(st(1_002), "a", Reason::Missing, 0); + d.ran(st(1_002), 7); + }); + assert!(n <= 6, "looked at {n} records"); + let n = looked_at_by(|| { + d.view(|| { + assert!(!d.holds(st(1_003), K + 9_998)); + d.ran(st(1_003), K + 9_998); + }); + }); + assert!(n <= 4, "looked at {n} records"); + let keys = d.state().deferred.iter().filter(|x| (1_000..1_004).contains(&x.stage.object)).map(|x| x.key).collect::>(); + assert_eq!(keys, [K + 1_001, K + 9_999]); + assert_eq!(d.list().len(), 1_997); + } } 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/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 9a637274df..b8f887ff37 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -88,12 +88,25 @@ pub(crate) struct Scan { /// Scan `src` (already through [`unescaped`]): the script itself (`file` none) or a file it includes /// (`file`: its path inside the script's folder), with the names it declares (`declared`, from /// [`declarations`] on the same `src`), the `#includepath` folders `paths` in force at its start and -/// its variables holding what `known` says. -pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>, paths: Rc<[String]>, known: &Known, acc: &mut Acc) -> Scan { +/// its variables holding what `known` says. Each of the `\u{…}` escapes `braced` that [`unescaped`] +/// read is syntax later than ECMAScript 3, reported when the scan reaches the escape's line. +pub(crate) fn scan<'s>( + src: &'s str, + declared: &Declared<'s>, + braced: &[Braced], + file: Option<&str>, + paths: Rc<[String]>, + known: &Known, + acc: &mut Acc, +) -> Scan { let mut lexer = Lexer::new(src, acc.limits.nesting); let mut a = Analyzer::new(acc, file, paths, known, declared); let mut breaks = LineBreaks::default(); + let mut braced = braced.iter().peekable(); for t in lexer.by_ref() { + while let Some((line, escape)) = braced.next_if(|(line, _)| *line <= t.line) { + a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape)); + } if let Some(code) = t.problem { a.problem(code, t.line); } @@ -103,6 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> Read::Token(sig) => a.token(sig), } } + for (line, escape) in braced { + a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape)); + } if let Some(line) = lexer.unclosed_xml { a.problem(report::UNTERMINATED_XML, line); } @@ -115,29 +131,61 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> Scan { includes, module, lines: lexer.lines(), known } } -/// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a -/// name may not hold where the escape stands stays as written. Line breaks don't change, so every -/// line keeps its number. -pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { - match unescape_names(&src, acc) { - Some(out) => Cow::Owned(out), - None => src, +/// A `\u{…}` escape in a name, which ECMAScript 3 does not have: its line and its text. +pub(crate) type Braced = (usize, String); + +/// `src` with the `\uXXXX` and `\u{…}` escapes in its names and private names written as the +/// characters they stand for (ECMA-262, Names and Keywords), and `src` itself when no name has one, +/// with the `\u{…}` escapes read, each listed once per line in a row. An escape for a character a +/// name may not hold where the escape stands stays as written, and so does a private name that +/// starts a line where the name it spells would start a preprocessor line. Line breaks don't +/// change, so every line keeps its number. +pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> (Cow<'a, str>, Vec) { + let mut braced = vec![]; + match unescape_names(&src, acc, &mut braced) { + Some(out) => (Cow::Owned(out), braced), + None => (src, braced), } } -fn unescape_names(src: &str, acc: &mut Acc) -> Option { +fn unescape_names(src: &str, acc: &mut Acc, braced: &mut Vec) -> Option { if !src.contains("\\u") { return None; } let mut out = String::with_capacity(src.len()); let mut changed = false; let mut lexer = Lexer::new(src, acc.limits.nesting); - for t in lexer.by_ref() { - if t.kind == T::Ident && t.text.contains("\\u") { - let name = unescape_name(t.text); - changed |= name != t.text; - out.push_str(&name); + // One more than the report keeps, so that the report is marked truncated. + let room = acc.limits.entries.saturating_add(1); + // Only blanks since the last line break (or the start). + let mut line_start = true; + let mut found = vec![]; + while let Some(t) = lexer.next() { + // A private name is `#` and a name. + let (mark, name) = match t.kind { + T::Ident => ("", t.text), + T::Other => t.text.strip_prefix('#').map_or(("", ""), |name| ("#", name)), + _ => ("", ""), + }; + let starts_line = line_start; + line_start = t.kind == T::Newline || (line_start && t.kind == T::Space); + if name.contains("\\u") { + found.clear(); + let written = unescape_name(name, t.line, &mut found, room); + for escape in found.drain(..) { + if braced.last() != Some(&escape) && braced.len() < room { + braced.push(escape); + } + } + // A private name that starts a line stays as written where the name it spells would + // start a preprocessor line. + if mark == "#" && starts_line && !lexer.private_line(&written) { + out.push_str(t.text); + continue; + } + changed |= written != name; + out.push_str(mark); + out.push_str(&written); } else { out.push_str(t.text); } @@ -146,25 +194,31 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written -/// as that character: a character a name may start with first, and one a name may hold after that. -fn unescape_name(text: &str) -> String { +/// The name `text`, on `line`, with each escape for a character a name may hold where it stands +/// written as that character: a character a name may start with first, and one a name may hold +/// after that. Each `\u{…}` escape read goes into `braced`, unless the last one there is the same +/// escape on the same line or `braced` holds `room` of them. +fn unescape_name(text: &str, line: usize, braced: &mut Vec, room: usize) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); - let after = rest.get(i + 2..).unwrap_or_default(); + let at = rest.get(i..).unwrap_or_default(); let first = out.is_empty(); - let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) - .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); + let c = crate::lex::escape(at).filter(|&(c, _)| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { - Some(c) => { + Some((c, len)) => { out.push(c); - rest = after.get(4..).unwrap_or_default(); + let source = at.get(..len).unwrap_or_default(); + let repeated = braced.last().is_some_and(|(l, s)| *l == line && s == source); + if source.starts_with("\\u{") && !repeated && braced.len() < room { + braced.push((line, source.to_string())); + } + rest = at.get(len..).unwrap_or_default(); } None => { out.push_str("\\u"); - rest = after; + rest = at.get(2..).unwrap_or_default(); } } } diff --git a/crates/scriptcheck/src/includes.rs b/crates/scriptcheck/src/includes.rs index f6e9c82b09..b4a7be7219 100644 --- a/crates/scriptcheck/src/includes.rs +++ b/crates/scriptcheck/src/includes.rs @@ -141,9 +141,9 @@ pub(crate) fn follow( read.insert(path.clone(), (status, Some(file))); let (text, _) = crate::decode(&b); let mut tallies = Acc::new(limits); - let text = analyze::unescaped(text, &mut tallies); + let (text, _) = analyze::unescaped(text, &mut tallies); let declared = analyze::declarations(&text, &mut tallies); - let scan = analyze::scan(&text, &declared, Some(&path), Rc::clone(&inc.paths), known, &mut tallies); + let scan = analyze::scan(&text, &declared, &[], Some(&path), Rc::clone(&inc.paths), known, &mut tallies); acc.steps = acc.steps.saturating_add(tallies.steps); known.add(scan.known, limits.entries); checked.push(ReadFile { path: path.clone(), bytes: b, paths: Rc::clone(&inc.paths) }); diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 715a0582b0..a9a805d87c 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -41,7 +41,9 @@ pub(crate) enum T { /// end or the next `{`. XmlMore, Punct, - /// A character no token starts with, or `#` and a name away from the start of a line. + /// A character no token starts with, or a private name: `#` and a name away from the start of a + /// line, or at its start where the name starts with an escape, or inside braces where the name + /// is not a directive's. Other, } @@ -166,14 +168,33 @@ pub(crate) fn ident_part(c: char) -> bool { } } -/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start -/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. -pub(crate) fn escaped(s: &str) -> Option { - s.strip_prefix("\\u")? - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) +/// The character the escape at the start of `s` stands for and the escape's length in bytes: `\u` +/// and four hexadecimal digits, or `\u{…}` with one or more hexadecimal digits of a value up to +/// U+10FFFF, leading zeros allowed (ECMA-262, UnicodeEscapeSequence and CodePoint). `None` when `s` +/// starts with neither, or when the escape gives a lone surrogate. +pub(crate) fn escape(s: &str) -> Option<(char, usize)> { + let rest = s.strip_prefix("\\u")?; + let Some(braced) = rest.strip_prefix('{') else { + let c = rest + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32)?; + return Some((c, 6)); + }; + // The value stops growing past U+10FFFF, so any number of digits is read once and never + // overflows. + let mut value: u32 = 0; + for (i, b) in braced.bytes().enumerate() { + match b { + b'}' if i > 0 => return char::from_u32(value).map(|c| (c, i + 4)), + _ => { + let digit = char::from(b).to_digit(16)?; + value = (value * 16 + digit).min(0x11_0000); + } + } + } + None } /// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is @@ -394,18 +415,66 @@ impl<'s> Lexer<'s> { } /// The rest of a name (its first character included): the characters [`ident_part`] allows, - /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four - /// hexadecimal digits, ends the name. + /// written out or as `\uXXXX` or `\u{…}` escapes ([`escape`]). An escape for any other + /// character, or a malformed one, ends the name. Each character of an escape is a step. fn name(&mut self) { loop { match self.peek() { - Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), + Some('\\') => match escape(self.rest()) { + Some((c, len)) if ident_part(c) => { + self.pos += len; + self.steps += len; + } + _ => break, + }, Some(c) if ident_part(c) => self.bump(c), _ => break, } } } + /// A name starts right after the `#` here: its first character written out, or an escape for a + /// character a name may start with. + fn private_name_follows(&self) -> bool { + let after = self.rest().get(1..).unwrap_or_default(); + match after.chars().next() { + Some('\\') => escape(after).is_some_and(|(c, _)| ident_start(c)), + Some(c) => ident_start(c), + None => false, + } + } + + /// The `#` at the start of this line starts a private name, not a preprocessor line: the name + /// starts with an escape, which no directive's name holds, or [`Lexer::private_line`] holds for + /// it. + fn private_at_line_start(&self) -> bool { + if !self.private_name_follows() { + return false; + } + let after = self.rest().get(1..).unwrap_or_default(); + after.starts_with('\\') || self.private_line(after) + } + + /// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line: + /// the line is inside braces or in the code of an XML literal, and the name `rest` starts with + /// is not a directive's. The name runs over every character a name may hold, written out or as + /// an escape, so `#include$size` is not `#include`. + pub fn private_line(&self, rest: &str) -> bool { + let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty(); + let mut end = 0; + while let Some(more) = rest.get(end..) { + let len = match more.chars().next() { + Some('\\') => escape(more).filter(|&(c, _)| ident_part(c)).map(|(_, len)| len), + Some(c) if ident_part(c) => Some(c.len_utf8()), + _ => None, + }; + let Some(len) = len else { break }; + end += len; + } + let name = rest.get(..end).unwrap_or_default(); + in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name)) + } + fn digits(&mut self, ok: fn(char) -> bool) { while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') { self.bump(c); @@ -871,10 +940,10 @@ impl<'s> Iterator for Lexer<'s> { } else if self.starts("/*") { problem = self.block_comment(); T::Comment - } else if c == '#' && self.line_start { + } else if c == '#' && self.line_start && !self.private_at_line_start() { self.until_line_break(); T::Directive - } else if c == '#' && self.peek_at(1).is_some_and(ident_start) { + } else if c == '#' && self.private_name_follows() { self.bump('#'); self.name(); T::Other @@ -906,7 +975,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { + } else if ident_start(c) || (c == '\\' && escape(self.rest()).is_some_and(|(c, _)| ident_start(c))) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/lib.rs b/crates/scriptcheck/src/lib.rs index 0c62419f1c..2bd8c60fba 100644 --- a/crates/scriptcheck/src/lib.rs +++ b/crates/scriptcheck/src/lib.rs @@ -166,14 +166,14 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: if invalid { acc.problem(report::NOT_UTF8, None, None, None); } - let text = analyze::unescaped(text, &mut acc); + let (text, braced) = analyze::unescaped(text, &mut acc); let declared = analyze::declarations(&text, &mut acc); let none = analyze::Known::default(); // With a loader, this first scan finds the includes and what the script's variables hold; its // tallies are left out. let mut tallies = report::Acc::new(limits); let pre = acc.clone(); - let scan = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc }); + let scan = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc }); acc.steps = acc.steps.saturating_add(tallies.steps); if scan.module { // A module's report holds its kind and its lines, and none of what the scan found. @@ -183,7 +183,7 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: return acc.finish(Kind::Module, scan.lines); } let Some(loader) = loader else { - let mut acc = settle(acc, pre, &text, &declared, scan.known); + let mut acc = settle(acc, pre, &text, &declared, &braced, scan.known); includes::not_read(&mut acc, scan.includes); return acc.finish(Kind::Script, scan.lines); }; @@ -191,17 +191,23 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known); // Each file's text and the names it declares are worked out once for all the scans below. let decoded: Vec<_> = read.iter().map(|file| decode(&file.bytes)).collect(); - let texts: Vec<_> = decoded.into_iter().map(|(text, invalid)| (analyze::unescaped(text, &mut acc), invalid)).collect(); - let declarations: Vec<_> = texts.iter().map(|(text, _)| analyze::declarations(text, &mut acc)).collect(); + let texts: Vec<_> = decoded + .into_iter() + .map(|(text, invalid)| { + let (text, braced) = analyze::unescaped(text, &mut acc); + (text, braced, invalid) + }) + .collect(); + let declarations: Vec<_> = texts.iter().map(|(text, ..)| analyze::declarations(text, &mut acc)).collect(); // A variable may get what it holds from another file's variable: the files are scanned again, // each with what is known so far, while that grows, at most three more times. for _ in 0..3 { let before = known.len(); let mut tallies = report::Acc::new(limits); - let more = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut tallies).known; + let more = analyze::scan(&text, &declared, &[], None, std::rc::Rc::from([]), &known, &mut tallies).known; known.add(more, limits.entries); - for ((file, (text, _)), declared) in read.iter().zip(&texts).zip(&declarations) { - let more = analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known; + for ((file, (text, ..)), declared) in read.iter().zip(&texts).zip(&declarations) { + let more = analyze::scan(text, declared, &[], Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known; known.add(more, limits.entries); } acc.steps = acc.steps.saturating_add(tallies.steps); @@ -210,12 +216,12 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: } } // The scans for the report: every file starts with what any file's variables hold. - let lines = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut acc).lines; - for ((file, (text, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) { + let lines = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &known, &mut acc).lines; + for ((file, (text, braced, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) { if *invalid { acc.problem(report::NOT_UTF8, None, Some(&file.path), None); } - analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc); + analyze::scan(text, declared, braced, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc); } acc.finish(Kind::Script, lines) } @@ -230,6 +236,7 @@ fn settle<'l>( pre: report::Acc<'l>, text: &str, declared: &analyze::Declared<'_>, + braced: &[analyze::Braced], mut known: analyze::Known, ) -> report::Acc<'l> { if known.len() == 0 { @@ -240,7 +247,7 @@ fn settle<'l>( for _ in 0..3 { let before = known.len(); let mut acc = pre.clone(); - let more = analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc).known; + let more = analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc).known; known.add(more, entries); if known.len() == before { acc.steps = acc.steps.saturating_add(spent); @@ -249,7 +256,7 @@ fn settle<'l>( spent = spent.saturating_add(acc.steps.saturating_sub(pre.steps)); } let mut acc = pre; - analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc); + analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc); acc.steps = acc.steps.saturating_add(spent); acc } diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 31cbef823a..853f212c11 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -271,7 +271,7 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { /// `src` with the escapes in its names written as the characters they stand for. fn unescaped(src: &str) -> String { let limits = Limits::default(); - crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).0.into_owned() } #[test] @@ -364,6 +364,155 @@ fn malformed_escapes_in_bulk_are_read_once_through() { assert_eq!(r.lines, 3); assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); + // Braced escapes: long runs of digits, closed and not, and ones that end at once. + let zeros = "0".repeat(100_000); + let src = format!( + "a\\u{{{zeros}62}};\n\\u{{{}\n{}\n{}\nalert(1);\n", + "f".repeat(100_000), + "\\u{".repeat(20_000), + "\\u{0000000061}\\u{110000}\\u{}\\u{D800}#\\u{61}".repeat(10_000) + ); + let r = report(&src); + assert_eq!(r.lines, 5); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[5][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + +#[test] +fn braced_escapes_in_names_are_read_by_their_value() { + for (src, want) in [ + ("\\u{61}pp", "app"), + ("a\\u{62}c", "abc"), + ("\\u{0000000061}", "a"), + ("\\u{2118}\\u{203F}", "\u{2118}\u{203F}"), + ("a\\u{1D7CE}", "a\u{1D7CE}"), + ("\\u{1D49C}", "\u{1D49C}"), + // Not at the start of a name, in no name, out of range, empty, a surrogate and not closed. + ("\\u{30}a", "\\u{30}a"), + ("a\\u{1F600}", "a\\u{1F600}"), + ("a\\u{110000}", "a\\u{110000}"), + ("a\\u{FFFFFFFFFFFFFFFFFFFF}b", "a\\u{FFFFFFFFFFFFFFFFFFFF}b"), + ("a\\u{}b", "a\\u{}b"), + ("a\\u{D800}", "a\\u{D800}"), + ("a\\u{61", "a\\u{61"), + ("a\\u{6 1}", "a\\u{6 1}"), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + assert_eq!(tokens("\\u{61}pp.x"), [(T::Ident, "\\u{61}pp"), (T::Punct, "."), (T::Ident, "x")]); + assert_eq!(tokens("a\\u{0000000062}"), [(T::Ident, "a\\u{0000000062}")]); + assert_eq!(tokens("a\\u{1F600}").get(..2), Some(&[(T::Ident, "a"), (T::Other, "\\")][..])); +} + +#[test] +fn braced_name_escape_reports_later_syntax_and_member() { + let r = report("\\u{61}pp.activeDocument.acmeName;\nvar a\\u{62}c = 1, \\u{0000000061}pp2 = 2;\n\\u{61}pp.x + \\u{61}pp.y;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.activeDocument.acmeName", "app.x", "app.y"]); + assert_eq!( + problems(&r), + [ + ("newerSyntax", Some(1), Some("\\u{61}")), + ("newerSyntax", Some(2), Some("\\u{62}")), + ("newerSyntax", Some(2), Some("\\u{0000000061}")), + ("newerSyntax", Some(3), Some("\\u{61}")), + ] + ); + // An escape that isn't a name's character, or isn't closed, makes no name and no report of one. + for src in [ + "\\u{1F600}pp.activeDocument;", + "\\u{110000}pp.activeDocument;", + "\\u{}pp.activeDocument;", + "\\u{D800}pp.activeDocument;", + "\\u{61pp.activeDocument;", + ] { + let r = report(src); + assert!(r.members.iter().all(|m| !m.name.starts_with("app")), "{src:?}: {:?}", r.members); + assert!(r.problems.iter().all(|p| p.detail.as_deref().is_none_or(|d| !d.starts_with("\\u{"))), "{src:?}: {:?}", r.problems); + } + // In an included file, the report names the file. + let mut m = mem(&[("lib.jsxinc", "\\u{61}pp.name;\n")]); + let r = check("main.jsx", b"#include 'lib.jsxinc'\n", Some(&mut m), &Limits::default()); + let found: Vec<_> = r.problems.iter().map(|p| (p.code, p.line, p.file.as_deref(), p.detail.as_deref())).collect(); + assert_eq!(found, [("newerSyntax", Some(1), Some("lib.jsxinc"), Some("\\u{61}"))]); +} + +#[test] +fn private_names_with_escaped_starts_are_single_tokens() { + assert_eq!(tokens("a #\\u0061b"), [(T::Ident, "a"), (T::Other, "#\\u0061b")]); + assert_eq!(tokens("this.#\\u0061pp"), [(T::Ident, "this"), (T::Punct, "."), (T::Other, "#\\u0061pp")]); + assert_eq!(tokens("o.#\\u{61}b"), [(T::Ident, "o"), (T::Punct, "."), (T::Other, "#\\u{61}b")]); + // An escape for a character no name starts with leaves `#` on its own. + assert_eq!(tokens("a #\\u0030b").get(..3), Some(&[(T::Ident, "a"), (T::Other, "#"), (T::Other, "\\")][..])); + let r = report("var q = this.#\\u0061pp;\nvar r = o.#\\u{61}b;\n"); + assert!(r.members.is_empty(), "{:?}", r.members); + assert_eq!( + problems(&r), + [("newerSyntax", Some(1), Some("#app")), ("newerSyntax", Some(2), Some("\\u{61}")), ("newerSyntax", Some(2), Some("#ab"))] + ); +} + +#[test] +fn private_name_reports_decoded_construct() { + let r = report("var q = this.#hid\\u0064en;\nvar r = this.#\\u0061\\u0062;\n"); + assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("#hidden")), ("newerSyntax", Some(2), Some("#ab"))]); +} + +#[test] +fn private_name_at_line_start_is_not_a_directive() { + let r = report(concat!( + "class Box {\n", + " #size = 1;\n", + " #\\u0061rea = 2;\n", + " #include 'a.jsxinc'\n", + " #\\u0069nclude = 4;\n", + " #\\u{69}nclude = 5;\n", + "}\n", + "#\\u0062ox = 3;\n", + "#pragma once\n", + "#target acme\n", + )); + let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect(); + assert_eq!(directives, [("include", 4), ("target", 10)]); + // Where the name a private name's escapes spell would start a preprocessor line, the report + // gives the name as written. + assert_eq!( + problems(&r), + [ + ("newerSyntax", Some(1), Some("class")), + ("newerSyntax", Some(2), Some("#size")), + ("newerSyntax", Some(3), Some("#area")), + ("newerSyntax", Some(5), Some("#\\u0069nclude")), + ("newerSyntax", Some(6), Some("\\u{69}")), + ("newerSyntax", Some(6), Some("#\\u{69}nclude")), + ("newerSyntax", Some(8), Some("#\\u0062ox")), + ("unknownDirective", Some(9), Some("#pragma")), + ] + ); +} + +#[test] +fn private_name_that_starts_with_a_directive_name_is_not_a_directive() { + let r = report(concat!( + "class Box {\n", + " #include$size = 1;\n", + " #include\u{e9} = 2;\n", + " #include\\u0041 = 3;\n", + " #include\\u{41} = 4;\n", + " #INCLUDE 'a.jsxinc'\n", + "}\n", + )); + let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect(); + assert_eq!(directives, [("include", 6)]); +} + +#[test] +fn xml_code_uses_escaped_names() { + let r = report("var x = {\\u{61}pp.activeDocument};\nvar y = \\u0061pp.z \\u{61};\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.activeDocument"]); + assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("\\u{61}"))]); + assert!(tokens("var x = {this.#\\u0061b};").contains(&(T::Other, "#\\u0061b"))); } #[test] @@ -1590,7 +1739,7 @@ fn include_lines_share_the_folders_of_the_include_path_before_them() { let limits = Limits::default(); let mut acc = report::Acc::new(&limits); let declared = analyze::declarations(&src, &mut acc); - let scan = analyze::scan(&src, &declared, None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc); + let scan = analyze::scan(&src, &declared, &[], None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc); let (first, rest) = scan.includes.split_first().unwrap(); assert_eq!((first.paths.len(), rest.len()), (64, 999)); assert!(rest.iter().all(|i| std::rc::Rc::ptr_eq(&i.paths, &first.paths)), "one list of folders, not a copy for each line"); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index eed8ecd335..8eb18626f3 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -188,7 +188,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // An artboard exported on its own is named after it (#3544), else after the document. // A name that is blank once the characters XML can't hold are dropped falls back too. let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name)); - let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title)); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title())); if !title.is_empty() { out.push_str(&w.indent(1)); out.push_str(&format!("{title}{nl}")); diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 4f91d6c970..7c30f23f5a 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { let info = &doc.metadata; let mut terms = vec![("format", "image/svg+xml".to_string())]; let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 645ba3d751..e5a5248400 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -258,6 +258,19 @@ impl Writer<'_> { self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); return self.text_outlines(n, t); } + // The space before the first character is written as its `dx`; one before a character + // after a line break, which the `` writes as a space, keeps its look as outlines. + let plain = t.plain_text(); + let mut at = 0; + let spaced_break = t.runs.iter().any(|r| { + let start = at; + at += r.text.len(); + r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0) + }); + if spaced_break { + self.warn("type on a path with space before a character after a line break is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -559,10 +572,17 @@ impl Writer<'_> { let a = self.attrs(&props); let offset = fmt_num(at * 100.0, 3); let mut s = format!(""); - for run in &drawn.runs { + let plain = drawn.plain_text(); + let first = drawn.runs.iter().position(|r| !r.text.is_empty()); + for (i, run) in drawn.runs.iter().enumerate() { let mut diff = run_diff(&base, self.char_props(&run.style, space)); diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); + let mut a = self.attrs(&diff); + // The space before the first character (`kerningBefore`) moves it along the path. + let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 }; + if before != 0.0 && before.is_finite() { + a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals))); + } s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } s.push_str(""); @@ -751,6 +771,14 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let x = g.xf.translation().x - offset; (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) } + // The space before a line's first character (`kerningBefore`) is in its advance and + // moves it along the line: a reader sets it that far along, without it. (Shrink + // Text to Fit scales it with the sizes.) + None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => { + let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale; + let before = if before.is_finite() { before } else { 0.0 }; + (g.advance - before, g.origin.x + before) + } None => (g.advance, g.origin.x), }; let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); @@ -838,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: // A reader spaces words and letters as the font does. p.justification = vectorcraft_doc::JustificationOptions::default(); } - if !kerning { - for r in &mut set.runs { + for r in &mut set.runs { + if !kerning { r.style.kerning.get_or_insert(0.0); } + // A reader puts no space before a line's first character (`kerningBefore`): the segment + // starts that much further along instead. + r.style.kerning_before = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1e852ee531..47fcfbaf14 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN; use importer::SymbolMade; pub(super) use layers::DEFAULT_FONT_SIZE; pub(crate) use layers::SUBLAYER; -use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name}; +use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title}; pub(super) use prepass::Edits; use prepass::{Extras, labels, prepass}; pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; @@ -158,6 +158,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); doc.units = units.unit; + // The file's own title, when it gives one, is the document's File Info title; the document's + // name stays what `Document::new` gave it, for the caller to name after the file. + doc.metadata.title = root_title(xml.root_element()); let mut im = Importer { doc, warnings, diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 15a77e37f0..a6d6a46b30 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option { root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) } +/// The title the file gives root element `root`: the text of its first `` child (the one +/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `<title>` inside a shape or group is +/// that element's tooltip, not the document's), its runs of white space made single spaces. `None` +/// without one, or when it is blank. +pub(super) fn root_title(root: XNode) -> Option<String> { + let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?; + let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect(); + (!text.is_empty()).then(|| text.join(" ")) +} + /// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. pub(super) fn marked_layer(g: XNode) -> bool { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index a031de7412..e858f1ba08 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -109,6 +109,60 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first +/// `<tspan>` to where the character is drawn, left aligned or anchored at a centred line's centre. +#[test] +fn the_space_before_a_lines_first_character_moves_its_tspan() { + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + // 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after + // the first "H" is inside its line, so its value does nothing. + let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))]; + let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default()); + // The first <tspan> on each baseline. + let mut starts: Vec<(f64, f64)> = vec![]; + for t in tspans(&svg) { + let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap()); + if starts.last().is_none_or(|&(_, last)| last != y) { + starts.push((x, y)); + } + } + assert_eq!(starts.iter().map(|s| s.0).collect::<Vec<_>>(), vec![3.0, 3.0], "{svg}"); + // Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws. + let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default()); + let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap(); + assert!((x - 101.5).abs() < 0.01, "{svg}"); + // A justified line, which a reader's own advances place word by word, starts there too. + let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))]; + let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default()); + assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}"); +} + +/// On a path, the space before the first character is its `dx` along the path. One before a +/// character after a line break, which the `<textPath>` can't write, is kept as outlines. +#[test] +fn the_space_before_the_first_character_on_a_path_is_its_dx() { + let doc = |runs: Vec<TextRun>| { + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = runs; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + vectorcraft_svg::export_full(&d, &ExportOptions::default(), None) + }; + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]); + let tags = tspans(&out.svg); + assert!(out.svg.contains("<textPath"), "{}", out.svg); + assert_eq!((attr(tags[0], "dx"), attr(tags[1], "dx")), (Some(3.0), None), "{}", out.svg); + let out = doc(vec![run("HO\n", style(12.0)), run("H", before)]); + assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("line break")), "{:?}", out.warnings); +} + /// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. #[test] fn proportional_metrics_write_palt() { diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs index 3318ed346f..2318eebc67 100644 --- a/crates/svg/tests/it/title_control_chars.rs +++ b/crates/svg/tests/it/title_control_chars.rs @@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() { assert!(svg.contains("<dc:subject>kw</dc:subject>")); assert!(svg.contains("<dc:rights>(c) me</dc:rights>")); } + +/// The root's `<title>` is the File Info title of the imported document ([`Document::info_title`]); +/// its name stays what `Document::new` gave it, and a shape's `<title>` is that shape's tooltip. +#[test] +fn the_roots_title_is_the_file_info_title_not_the_documents_name() { + let svg = |inner: &str| { + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{inner}<rect width="5" height="5"><title>Tip"#) + }; + let d = import(&svg("\n Access\n paths ")).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths")); + for none in [svg(""), svg(" ")] { + let d = import(&none).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1")); + } +} + +/// Exports write the File Info title, where the document has one, in place of its name. +#[test] +fn exports_write_the_file_info_title() { + let mut d = Document::new(200.0, 200.0); + d.title = "box.svg".into(); + d.metadata.title = Some("Access paths".into()); + let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() }); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + assert!(!svg.contains("box.svg"), "{svg}"); +} diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index a4505f9596..c2838fbbe8 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize { s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) } +/// Does the character at byte `i` of `s` start a line by itself: the first character of the text, +/// of a paragraph, or after a forced line break (U+2028)? +pub fn starts_line(s: &str, i: usize) -> bool { + i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}'])) +} + +/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field, +/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it +/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it +/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character +/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before +/// the caret, so it is the space before the character after it (#5111). None at the start of an +/// empty line, where no character follows either. +pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range, bool)> { + let i = floor_char(s, i); + if !starts_line(s, i) { + return Some((prev_char(s, i)..i, false)); + } + let c = s.get(i..)?.chars().next()?; + (c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true)) +} + /// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and /// tone marks, an accented letter or an emoji sequence is passed over whole. pub fn prev_cluster(s: &str, i: usize) -> usize { diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index b7c4f1060e..435bbc4ed7 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -38,7 +38,8 @@ pub(crate) fn is_harakat(c: char) -> bool { /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the /// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike` +/// and change no glyph, advance or position. Nor is the space before a line's first character +/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` /// (which also ignores baseline shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. @@ -51,6 +52,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { leading, tracking, kerning, + kerning_before: _, optical_kerning, baseline_shift, h_scale, diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 10c44eb73e..6e02df73cd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool { /// /// It stands in glyph space as it stands in vertical point type's line space: a quarter turn /// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift -/// kept across the column and a leading space taken off moving it along the path, then moved across +/// kept across the column and a leading space taken off (or the space before a column's first +/// character) moving it along the path, then moved across /// the path as a glyph lying on its side is. fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { let cell = if takes_cell(g) { upright_cell(g) } else { advance }; let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); - let mut turn = - Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em)) + * shift + * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) + * unshift; // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on // a path that runs to the left: it turns half a turn about its cell's center. let tangent = Vec2::new(angle.cos(), angle.sin()); @@ -359,19 +362,20 @@ impl Ctx<'_> { } else { self.db.outline(&g.face, g.gid) }; - // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an - // upright one in vertical type by moving it up the column once it stands upright. + // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one + // moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an + // upright one in vertical type by moving it along the column once it stands upright. let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); - let lead = if upright { 0.0 } else { g.lead }; + let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by // right-to-left manual kerning as a glyph is. - Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf, + Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf, // Baseline shift moves the glyph across its line, and Character Rotation then turns it // about its shifted pen position, so the shift is across the line at any rotation. // Manual kerning and composition trim move that pen position along the line. None => { - Affine::translate((g.kern_shift - lead, -g.bshift)) + Affine::translate((kern_shift - lead, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy) @@ -379,7 +383,7 @@ impl Ctx<'_> { }; // The turning point in font units, which later moves of the glyph's transform keep. let pivot = match local.determinant() { - det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift), + det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift), _ => Point::ZERO, }; let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { @@ -490,9 +494,10 @@ impl Ctx<'_> { // space after the cell, and must not push the glyph off the centre line). let em = self.style_at(g.byte).size; let cell = if takes_cell(g) { upright_cell(g) } else { advance }; - // A leading space taken off moves it up the column after the turn (moving the - // turning point instead would move it across the column too). - let turn = Affine::translate((-g.lead, 0.0)) + // A leading space taken off moves it up the column after the turn, and the space + // before a column's first character down it (moving the turning point instead + // would move it across the column too). + let turn = Affine::translate((g.kern_shift - g.lead, 0.0)) * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))); m = shift * turn * unshift * m; // Its own advance across, from where the cell centres it: where the shaper set the @@ -527,10 +532,16 @@ impl Ctx<'_> { }; // Where its own width starts along its advance: the shaper's placement, a leading space // taken off and right-to-left manual kerning move the glyph along the line. An upright or - // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance. - let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an + // upright one after the space before a column's first character. + let along = { let [a, b, ..] = pre.as_coeffs(); - Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0) + a.hypot(b) + }; + let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0) + } else if upright { + Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0) } else { 0.0 }; @@ -676,6 +687,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -737,6 +749,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index a259ce0881..0f033d4fcb 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -734,8 +734,20 @@ fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { } /// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`, in the -/// language of the word's first letter. +/// language of the word's first letter; never next to a Hangul letter, which takes no hyphen, and +/// nowhere in a Korean word that Korean Line Break Anywhere off keeps whole. fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { + if g.get(ws..we).is_some_and(|word| word.iter().any(|h| h.keeps_word)) { + return Vec::new(); + } + let mut points = word_hyphen_breaks(text, g, ws, we, opts); + let hangul = |i: usize| g.get(i).is_some_and(|h| h.inline.is_none() && crate::shape::is_hangul(h.ch)); + points.retain(|&k| !hangul(k.wrapping_sub(1)) && !hangul(k)); + points +} + +/// [`hyphen_breaks`] before the points next to Hangul are left out. +fn word_hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { // Strip leading/trailing punctuation (quotes, commas). let (mut a, mut b) = (ws, we); while a < b && !g[a].is_letter() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 0fd3c1275d..d7ac206424 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -10,6 +10,7 @@ use super::{ ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +use crate::shape::keep_korean_words; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, /// with kinsoku on)? @@ -224,6 +225,10 @@ fn prepare_paragraph<'a, 'text>( let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Korean breaks between words, unless Korean Line Break Anywhere lets it break between syllables. + if !para.korean_line_break_anywhere { + keep_korean_words(&mut sg); + } // Japanese composition: consecutive punctuation shares one half-em space. let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e077707a4b..0c8ffb4e2f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -33,6 +33,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -513,3 +514,101 @@ fn composition_counts_the_narrower_line_end() { assert_eq!(lines(38.0, &half, &hyphenated), None); assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); } + +/// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean +/// Line Break Anywhere on). +fn korean(text: &str, anywhere: bool) -> Vec { + let mut glyphs = full_width(text); + if !anywhere { + crate::shape::keep_korean_words(&mut glyphs); + } + glyphs +} + +/// Korean Line Break Anywhere off: both composers break a Korean word only at its spaces, so a +/// word that doesn't fit moves to the next line whole; on, they break between its syllables. +#[test] +fn korean_words_break_at_spaces_unless_line_break_anywhere() { + use KinsokuShoriType::PushOutOnly; + let text = "가나다 라마바"; + let greedy = + |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); + // 50 pt holds "가나다 라" and not "가나다 라마". + assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space"); + assert_eq!(greedy(&korean(text, true), 50.0), 5, "on: the line ends after 라"); + assert_eq!(ends(&korean(text, false)), [4]); + assert_eq!(ends(&korean(text, true)), [1, 2, 4, 5, 6]); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let off = korean(text, false); + let cands = candidates(text, &off, None, Kinsoku::Hard); + assert_eq!(compose(&off, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms), Some(vec![(4, false), (7, false)])); + // The first word is measured whole, as the line loop measures it. + assert_eq!(breaking::word_width(&off, 0, &[], Kinsoku::Hard), 30.0); + assert_eq!(breaking::word_width(&korean(text, true), 0, &[], Kinsoku::Hard), 10.0); + // A word wider than the line still breaks, at the syllable that overflows. + assert_eq!(greedy(&korean("가나다라마바", false), 25.0), 2); + // A soft return still ends the line. + let soft = "가나\u{2028}다라"; + assert_eq!( + break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0, + 3 + ); +} + +/// Korean Line Break Anywhere off keeps the Han, kana and full-width letters of a Korean word with +/// it, lets the line break after East Asian punctuation in it (as kinsoku allows), and leaves +/// text without Hangul its own breaks. On, No Break still keeps its characters together. +#[test] +fn korean_words_keep_their_letters_and_break_after_punctuation() { + let ends = |text: &str, anywhere: bool| candidates(text, &korean(text, anywhere), None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); + assert_eq!(ends("漢字한국 ab", false), [5], "Han in a Korean word stays with it"); + assert_eq!(ends("日本語 한국어", false), [1, 2, 4], "Japanese keeps its breaks"); + assert_eq!(ends("가나。다라", false), [3], "after the full stop"); + assert_eq!(ends("「가나」다", false), [4], "after the closing bracket, not the opening one"); + assert_eq!(ends("가나AB다", false), Vec::::new(), "full-width letters stay with it"); + let text = "가나다라"; + let mut glyphs = korean(text, true); + for g in &mut glyphs[1..3] { + g.no_break = true; + } + let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0; + assert_eq!(end, 1, "No Break keeps 나다 together"); +} + +/// Hyphenation adds no hyphen to a Korean word, even where a hyphenation exception marks one: the +/// word that overflows breaks without one, and the every-line composer is offered no hyphenated +/// break in it. +#[test] +fn korean_words_take_no_hyphen() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + let text = "가나다라"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나-다라", || { + let glyphs = korean(text, false); + let line = break_line( + text, + &glyphs, + 0, + 25.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + ); + assert_eq!(line, (2, false)); + assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0)); + }); + // Nor between the Latin letters of a word that also holds Hangul. + let text = "가나foobar"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나foo-bar", || { + let hyphens = + |anywhere: bool| candidates(text, &korean(text, anywhere), Some(&opts), Kinsoku::Hard).iter().filter(|b| b.hyphen > 0.0).count(); + assert_eq!(hyphens(false), 0); + assert_eq!(hyphens(true), 1, "on, the word's Latin letters hyphenate as before"); + }); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1e4aeb41d9..d2e3cf1dc8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -49,7 +49,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; -pub use shape::spacing_scale; +pub use shape::{line_start_space, spacing_scale}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 3c56b2c914..ba7339bc6b 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -39,7 +39,9 @@ pub(crate) struct SGlyph { pub dy: f64, /// Right to left: the run's manual kerning, which moves the glyph right along the baseline, /// outside its Character Rotation; with whole-point advances, the difference the kerning makes - /// to the cluster's rounded advance. 0 otherwise. + /// to the cluster's rounded advance. Left to right and in vertical type: the space before a + /// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the + /// same way. 0 otherwise. pub kern_shift: f64, /// Outline scale (font units -> points). pub sx: f64, @@ -74,6 +76,9 @@ pub(crate) struct SGlyph { /// A word of a script written without spaces (Thai, Lao, Khmer, Myanmar) ends after the /// glyph's cluster, so a line may break there ([`mark_word_ends`]). pub word_end: bool, + /// A letter of a Korean word kept whole (Korean Line Break Anywhere off): a CJK character + /// here doesn't let the line break after it ([`keep_korean_words`]). + pub keeps_word: bool, /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, @@ -156,7 +161,7 @@ impl SGlyph { self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}') - || is_cjk(self.ch) + || (is_cjk(self.ch) && !self.keeps_word) } /// A soft (discretionary) hyphen: invisible unless a line breaks after it. pub fn is_soft_hyphen(&self) -> bool { @@ -325,6 +330,30 @@ pub(crate) fn mark_word_ends(text: &str, start: usize, sg: &mut [SGlyph]) { } } +/// Korean Line Break Anywhere off: mark the letters of each Korean word of `sg` (a paragraph's +/// glyphs) as [`SGlyph::keeps_word`], so the word breaks only at its spaces and punctuation. A +/// Korean word is the run of glyphs between spaces and forced line breaks when any of them is +/// Hangul; its Han, kana and full-width letters are kept with it. Punctuation in it still lets +/// the line break after it, and runs without Hangul keep their own breaks. +pub(crate) fn keep_korean_words(sg: &mut [SGlyph]) { + let mut start = 0; + while start < sg.len() { + let len = sg[start..].iter().position(|g| g.is_space() || g.ch == '\u{2028}').unwrap_or(sg.len() - start); + let word = &mut sg[start..start + len]; + if word.iter().any(|g| g.inline.is_none() && is_hangul(g.ch)) { + for g in word.iter_mut().filter(|g| g.inline.is_none()) { + g.keeps_word = g.ch.is_alphanumeric(); + } + } + start += len + 1; + } +} + +/// Is `c` Hangul: a syllable, a jamo or a compatibility jamo (full or half width)? +pub(crate) fn is_hangul(c: char) -> bool { + c.script() == Script::Hangul +} + pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } @@ -522,6 +551,37 @@ pub(crate) fn shape_runs( } } +/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of +/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its +/// cluster takes the space in its advance, as manual kerning takes the space after a character, +/// and left to right (and in vertical type) its glyphs are drawn that much later on the line +/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on +/// that side. +fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 { + if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}'])) + { + return 0.0; + } + Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0) +} + +/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets +/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it +/// starts a line by itself, else 0. For writers that place a line's first character themselves. +pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 { + let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase); + space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical)) +} + +/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small +/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size. +fn glyph_size(st: &CharStyle, small: bool) -> f64 { + let full = st.size.max(0.0); + let (pos_scale, _) = st.position.scale_shift(full); + let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; + full * pos_scale * small_scale +} + /// Line segments of a glyph outline in font units, cached for the current shaping pass. type Profile = Vec<(f64, f64, f64, f64)>; type CachedProfile = (Profile, (f64, f64)); @@ -817,8 +877,11 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that /// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. +/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space +/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it +/// from its first character, so it doesn't split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { + let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; if a == b { return true; } @@ -836,7 +899,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]) +/// and the tracking, as long as the tracking keeps or drops standard /// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. @@ -849,6 +913,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { leading: _, tracking, kerning: _, + kerning_before: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -948,8 +1013,9 @@ fn shaping_script(c: char) -> Script { /// /// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its /// vertical centre on the middle of the cap height raised by the inline's shift (see -/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual -/// kerning, which right to left in horizontal type also moves the art right by the value, as it +/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual +/// kerning and the space before it at a line's start ([`space_before`]). Right to left in +/// horizontal type the manual kerning also moves the art right by the value, as it /// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the /// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the /// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same @@ -983,7 +1049,8 @@ fn inline_glyph( let spacing_em = size * spacing_scale(st, vertical); let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + tracking + manual_kern; + let before = space_before(text, range.start, st, spacing_em); + let adv = width + tracking + manual_kern + before; SGlyph { face: face.clone(), gid: 0, @@ -994,7 +1061,7 @@ fn inline_glyph( tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, - kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, + kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before }, // Not used to draw (the art has its own transform), but they make the glyph's em box // its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a // character of its run's size, whatever its scale (see `layout::glyph_em`). @@ -1017,6 +1084,7 @@ fn inline_glyph( level, inline: Some(InlineBox { art: drawn, xf }), word_end: false, + keeps_word: false, no_break: st.no_break, vertical_roman: false, cursive: false, @@ -1079,9 +1147,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. - let (pos_scale, pos_shift) = st.position.scale_shift(full); - let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; - let size = full * pos_scale * small_scale; + let (_, pos_shift) = st.position.scale_shift(full); + let size = glyph_size(st, small); let k = size / face.upem; let km = full / face.upem; let hs = st.h_scale / 100.0; @@ -1407,15 +1474,19 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); + let before = space_before(text, cl, gst, spacing_em); // Manual kerning spaces a character from the next one in reading order. Right to left, // that one is drawn to its left: the cluster's glyphs move right along the baseline by // the value, so that the space its last glyph's advance gains is at the cluster's left. + // The space before a line's first character moves them the other way. if level.is_rtl() && !feats.vertical { kern_shift = manual_kern; + } else { + kern_shift = before; } if last_in_cluster { tracked = gst.tracking / 1000.0 * spacing_em; - adv += tracked + manual_kern; + adv += tracked + manual_kern + before; } } out.push(SGlyph { @@ -1446,6 +1517,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe level: *level, inline: None, word_end: false, + keeps_word: false, no_break: gst.no_break, vertical_roman: gst.standard_vertical_roman, cursive, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8934090d5a..bcb233b8c3 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -19,6 +19,7 @@ mod inline; mod joiners; mod justify; mod kept_layouts; +mod kerning_before; mod ligature_carets; mod path_joins; mod ruby; @@ -2515,6 +2516,41 @@ fn thai_loanwords_stay_whole() { assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}"); } +/// Korean Line Break Anywhere (a paragraph attribute): off, a Korean word that does not fit moves +/// to the next line whole, with either composer and in vertical type; on, the line breaks between +/// its syllables. Each paragraph follows its own setting. +#[test] +fn korean_words_stay_whole_unless_line_break_anywhere_is_on() { + let text = "가나다 라마바"; + let first = "가나다 ".len(); + let measure = |vertical: bool| { + let mut t = point("가나다 라", style(20.0)); + t.vertical = vertical; + layout(db(), &t).glyphs.iter().map(|g| g.advance).sum::() + }; + let ends = |t: &TextObject| layout(db(), t).lines.iter().map(|l| l.end).collect::>(); + for vertical in [false, true] { + let fits = measure(vertical); + let frame = if vertical { Rect::new(0.0, 0.0, 400.0, fits + 1.0) } else { Rect::new(0.0, 0.0, fits + 1.0, 400.0) }; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(text, style(20.0), frame, Justify::Left); + (t.vertical, t.para.composer) = (vertical, composer); + assert_eq!(ends(&t), [first, text.len()], "{composer:?} vertical {vertical}: off"); + t.para.korean_line_break_anywhere = true; + let on = ends(&t); + assert!(on.iter().any(|&e| e != first && e != text.len()), "{composer:?} vertical {vertical}: on {on:?}"); + } + } + // Per paragraph: the first breaks between syllables, the second between words. + let fits = measure(false); + let mut t = area(&format!("{text}\n{text}"), style(20.0), Rect::new(0.0, 0.0, fits + 1.0, 400.0), Justify::Left); + t.para.composer = Composer::SingleLine; + let anywhere = vectorcraft_doc::ParaStyle { korean_line_break_anywhere: true, ..t.para.clone() }; + t.set_paragraph_styles(vec![anywhere, t.para.clone()]); + let second = text.len() + 1; + assert_eq!(ends(&t), ["가나다 라".len(), text.len(), second + first, second + text.len()]); +} + /// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps /// at the space without the mark. #[test] diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index e590f103c7..9bb7700d66 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -171,6 +171,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_dash = a.stroke_dash.clone(); b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); + b.kerning_before = a.kerning_before; *a == b } @@ -203,6 +204,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(leading, Some(60.0), false); field!(tracking, 20.0, false); field!(kerning, Some(0.0), false); + field!(kerning_before, 250.0, true); field!(optical_kerning, true, false); field!(baseline_shift, 1.0, false); field!(h_scale, 90.0, false); @@ -312,6 +314,36 @@ fn harakat_ranges_normalise_their_range() { /// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone /// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character; /// everything else stays at the cluster start (conservative). +/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret +/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to +/// left the space is past the letter's right side, so every glyph stays where it was and the line +/// is the space longer. Checked with the in-memory Arabic test font (its fatha a `GDEF` mark) in +/// every environment, and with an installed Arabic face when there is one. +#[test] +fn a_space_before_the_first_letter_keeps_its_coloured_harakat() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1); + check_spaced_harakat(&db, crate::test_fonts::lam_alef_family(b"rlig")); + if let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") { + check_spaced_harakat(FontDb::global(), &face.family); + } +} + +fn check_spaced_harakat(db: &FontDb, family: &str) { + const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 + let coloured = text_with_harakat(WORD, family, Some(red())); + let mut spaced = coloured.clone(); + crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0); + let (a, b) = (layout(db, &coloured), layout(db, &spaced)); + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::>(); + assert_eq!(placed(&a), placed(&b), "{family}: the same glyphs where they were"); + assert!(painted(&spaced, &b, &red()) > 0, "{family}: harakat painted apart"); + assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "{family}: the harakat keep their colour"); + // 250/1000 of the 48 pt em. + let length = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::(); + assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b)); +} + #[test] fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs new file mode 100644 index 0000000000..f3d9e785a8 --- /dev/null +++ b/crates/text/src/tests/kerning_before.rs @@ -0,0 +1,136 @@ +//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111): +//! the first character of the text, of a paragraph or after a forced line break moves along its line +//! by the value, the rest of the line follows, and the value does nothing anywhere else. +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// `t` with `kerning_before` set on the character at `byte`. +fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +#[test] +fn the_first_character_moves_along_its_line_and_the_rest_follow() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + // 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back. + let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]); + assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}"); + let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]); + assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}"); + // The line is that much longer, so a centred line moves its first character half as far. + let mut centred = plain.clone(); + centred.para.justify = Justify::Center; + let half = moves(¢red, &spaced(¢red, 0, 250.0), &[0, 2]); + assert!(near(&half, &[12.5, 12.5]), "{half:?}"); +} + +#[test] +fn it_does_nothing_on_a_character_inside_a_line() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]); + assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}"); + // Nor on the first character of a line that wrapped in area type: the line break moves. + let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left); + let wrapped = layout(db(), &area); + let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line; + assert_eq!(second_line, 1, "the second HOH wraps"); + let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]); + assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() { + let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0)); + let moved = moves(¶graphs, &spaced(¶graphs, 3, 250.0), &[0, 1, 3, 4]); + assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}"); + let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0)); + let after = "HO\u{2028}".len(); + let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]); + assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}"); + // On a line break it stays: an empty line has nothing to move. + let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0)); + let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]); + assert!(near(&moved, &[0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn it_takes_the_em_of_the_character_as_manual_kerning_does() { + // Horizontal Scale 50%: half the space, as kerning after a character takes it. + let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) }); + let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]); + assert!(near(&moved, &[12.5, 12.5]), "{moved:?}"); +} + +#[test] +fn right_to_left_the_space_is_at_the_right_of_the_first_character() { + // The first character in reading order is drawn rightmost; the space before it is on its right, + // so the two keep their distance, and the line is 25 pt longer. + let plain = TextObject::point(Point::ZERO, "אב", style(100.0)); + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left"); + let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2); + assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b)); + let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b)); +} + +#[test] +fn in_vertical_type_the_first_character_moves_down_its_column() { + // An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt) + // down the column, and stay in it. + for text in ["§§", "ab"] { + let mut plain = TextObject::point(Point::ZERO, text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + for i in 0..2 { + let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box()); + assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + } + } +} + +#[test] +fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() { + // Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one + // glyph, and moves. + let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) }; + let plain = TextObject::point(Point::ZERO, "fit", serif); + let glyphs = |t: &TextObject| layout(db(), t).glyphs.len(); + assert_eq!(glyphs(&plain), 2, "the font joins fi"); + let kerned = spaced(&plain, 0, 250.0); + assert_eq!(glyphs(&kerned), 2, "still joined"); + let moved = moves(&plain, &kerned, &[0, 2]); + assert!(near(&moved, &[25.0, 25.0]), "{moved:?}"); + assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 1530caf105..2d945c1439 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -341,6 +341,9 @@ impl Revolve { pub struct Face { pub points: Vec, pub depth: f64, + /// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the + /// side the viewer sees. + pub normal: [f64; 3], pub brightness: f64, pub gradient: Option, /// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp. @@ -453,6 +456,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi Ok(Some(Face { points, depth: vertices.iter().map(|v| v.2).sum::() / vertices.len() as f64, + normal: [normal.0, normal.1, normal.2], brightness, gradient, illumination, @@ -470,6 +474,12 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result Result, String> { + Ok(revolve(path, bounds, options)?.into_iter().map(Facet::of_face).collect()) +} + const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index d486f443cf..18c9959b19 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -294,6 +294,24 @@ impl Facet { } PathData::from_bezpath(&bp) } + + /// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting. + /// A face that joins the profile's ends round the axis is a side; a cap is an end. + pub(crate) fn of_face(face: crate::Face) -> Self { + Facet { + contours: vec![face.points], + depth: face.depth, + brightness: face.brightness, + illumination: face.illumination, + normal: face.normal, + rgb_gradient: None, + outline: face.outline, + side: !face.cap, + away: false, + #[cfg(test)] + view: vec![], + } + } } fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 { diff --git a/crates/three-d/src/tests/classic.rs b/crates/three-d/src/tests/classic.rs index 1d807b4a3e..f460297453 100644 --- a/crates/three-d/src/tests/classic.rs +++ b/crates/three-d/src/tests/classic.rs @@ -94,3 +94,32 @@ fn surfaces_round_trip_through_their_ids() { assert_eq!(ClassicSurface::parse("glossy"), None); assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic); } + +#[test] +fn revolve_facets_keep_each_face_normal_turned_to_the_viewer() { + // A cylinder of radius 30 about the left edge, with closed ends, tipped toward the viewer. + let mut b = BezPath::new(); + b.move_to((100.0, 0.0)); + b.line_to((130.0, 0.0)); + b.line_to((130.0, 100.0)); + b.line_to((100.0, 100.0)); + let path = PathData::from_bezpath(&b); + let options = Revolve { rotation_x: -30.0, shade: false, smooth_shading: false, ..Revolve::default() }; + let faces = revolve(&path, path.bounds().unwrap(), options).unwrap(); + let facets = revolve_facets(&path, path.bounds().unwrap(), options).unwrap(); + assert_eq!(facets.len(), faces.len()); + for (facet, face) in facets.iter().zip(&faces) { + let [x, y, z] = facet.normal; + assert!(((x * x + y * y + z * z).sqrt() - 1.0).abs() < 1e-9, "unit length: {:?}", facet.normal); + assert!(z >= 0.0, "turned to the viewer: {:?}", facet.normal); + assert_eq!((facet.depth, facet.side), (face.depth, !face.cap)); + } + // Tipped 30°, the wall faces nearest the viewer turn 30° down from it, and the flat top and bottom + // (the profile's horizontal edges swept round) face up and out at 30°. + let (s, c) = 30f64.to_radians().sin_cos(); + assert!(facets.iter().any(|f| f.normal[2] > c - 0.01 && (f.normal[1] - s).abs() < 0.01), "{facets:?}"); + assert!(facets.iter().any(|f| (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6), "{facets:?}"); + // Moved off the axis, the profile's open ends close with caps, which are ends rather than sides. + let ring = revolve_facets(&path, path.bounds().unwrap(), Revolve { offset: 10.0, ..options }).unwrap(); + assert!(ring.iter().any(|f| !f.side && (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6)); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index f66187fcf9..db8f5b0df7 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -293,6 +293,7 @@ mod tests { Face { points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], depth: brightness, + normal: [0.0, 0.0, 1.0], brightness, gradient: None, illumination: [brightness; 3], diff --git a/crates/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/guides/snap.rs b/crates/tools/src/guides/snap.rs index 32941c5f71..6f0b50d4b5 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -119,6 +119,13 @@ impl PointSnap { Self { targets } } + /// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the + /// window as targets ([`Targets::for_drawing`]), live text as Snap to Glyph asks. + pub fn drawn(cx: &ToolContext) -> Self { + let glyphs = (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap); + Self::new(cx, || Targets::for_drawing(cx.doc, &[], visible(cx), glyphs)) + } + /// Where `p` goes, and the guides showing why. pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec) { self.snap_from(cx, p, None) 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/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 97d8bed289..a90770d3fb 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -9,13 +9,14 @@ //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its //! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden -//! or locked layer, can't be picked. +//! or locked layer, can't be picked. The rulers' origin, dragged out of their corner +//! ([`OriginDrag`]), snaps as a drawn point does. use serde_json::json; use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; -use crate::guides::Targets; +use crate::guides::{PointSnap, Targets}; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; /// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection @@ -247,6 +248,39 @@ impl NewGuide { } } +/// The rulers' origin dragged out of the box where they meet, with any tool: it snaps as a drawn +/// point does ([`PointSnap::drawn`]), with Smart Guides onto the anchors, centres, edges and paths +/// of the art and the artboards or into line with them, with Snap to Point onto the anchors and +/// along the ruler guides, else onto whole pixels or the grid. The targets are gathered when the +/// drag begins. It changes nothing: the origin is set where the button is released. +pub struct OriginDrag { + snap: PointSnap, + /// The guides showing what the origin snapped to. + guides: Vec, +} + +impl OriginDrag { + pub fn new(cx: &ToolContext) -> Self { + Self { snap: PointSnap::drawn(cx), guides: vec![] } + } + + /// Where the origin goes with the pointer at `p` (none off the canvas). + pub fn pointer(&mut self, cx: &ToolContext, p: Option) -> Option { + let Some(p) = p else { + self.guides.clear(); + return None; + }; + let (at, guides) = self.snap.snap(cx, p); + self.guides = guides; + Some(at) + } + + /// The guides showing what the origin snapped to. + pub fn overlays(&self) -> Vec { + self.guides.clone() + } +} + /// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for /// the rest of the gesture. #[derive(Default)] diff --git a/crates/ui-egui/src/addons/image/tests.rs b/crates/ui-egui/src/addons/image/tests.rs index 349c35533e..64563e611b 100644 --- a/crates/ui-egui/src/addons/image/tests.rs +++ b/crates/ui-egui/src/addons/image/tests.rs @@ -56,6 +56,26 @@ fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) { crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), key.into(), v); } +/// Properties' Quick Actions and the Control bar offer Remove Background for a selected image, and +/// the Control bar's button removes it at once (no dialog) with the defaults, as one step. +#[test] +fn properties_and_the_control_bar_remove_the_background_at_once() { + use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame}; + let mut app = app(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let n = steps(&app); + assert!(has(&properties_frame(&mut app, 280.0), "Remove Background"), "Properties offers it"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let bar = control_frame(&mut app, &ctx, vec![]); + click_control(&mut app, &ctx, at(&bar, "Remove Background")); + assert!(app.ui.dialog.is_none(), "it runs at once"); + assert_eq!(steps(&app), n + 1, "one undo step"); + // The defaults (tolerance and softness 10): the white goes, the near-white ring fades. + let [corner, ring, block] = alphas(&app); + assert!(corner == 0 && ring > 0 && ring < 255 && block == 255, "{:?}", alphas(&app)); +} + /// Object › Plug-ins › Image Tools › Remove Background… opens the command dialog on Edge Color /// with Preview off. With Preview on the canvas shows the result for the values in the dialog, /// each change worked out again from the image as it was; Cancel puts the image back with no step diff --git a/crates/ui-egui/src/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.rs b/crates/ui-egui/src/canvas.rs index 50136f1a9c..6e6806e075 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,7 +50,7 @@ use fill::fill_uncovered; use fill::{fill_view, polygon_area}; #[cfg(test)] use grid::ruler_rects; -use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds}; +use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, rulers_hidden, textured_quad, view_bounds}; pub(crate) use grid::{RulerScale, checker_texture, ruler_label, ruler_origin_dragging}; #[cfg(test)] pub(crate) use grid::{TRANSPARENCY_GRID, vertical_label_height}; @@ -283,7 +283,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.ctx().request_repaint_after_secs(secs as f32); } let hover_still = wait.is_none(); - let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None }; + let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { rulers_hidden(app, ui) }; crate::document_drag::drop_on_canvas(app, &resp, &xf); panel_drop(app, ui, &resp, &xf); // In Presentation Mode a right click or a long touch opens no context menu. diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index c5bbe90882..cd98a214b7 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -53,7 +53,10 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: /// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the /// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries /// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on -/// top of its appearance). A chip follows the pointer meanwhile. +/// top of its appearance), and a brush is applied to it (`brush.apply`, `brush.addFromLibrary` +/// for a brush library's). A Brushes panel scatter, art or pattern +/// brush dropped where there is no art places a copy of its art there (`brush.placeArt`). A chip +/// follows the pointer meanwhile. pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { crate::panels::swatches::drag_preview(app, ui.ctx()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; @@ -86,7 +89,19 @@ pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respons } return; } - let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; + let hit = hit_at(app, xf.to_doc(pos), xf.zoom); + // A scatter, art or pattern brush from the Brushes panel dropped where there is no art: a copy + // of its art, centered where it is dropped, to edit and Alt-drag back onto the brush. + if let (None, widgets::PanelDrag::Brush { name, def }) = (&hit, &*d) + && matches!(def["type"].as_str(), Some("scatter" | "art" | "pattern")) + { + let at = xf.to_doc(pos); + if let Err(e) = app.run("brush.placeArt", json!({"name": name, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } + let Some(hit) = hit else { return }; let Some(st) = app.session.active() else { return }; let (cmd, params) = match &*d { widgets::PanelDrag::Paint { params, .. } => { diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 930daf8fd4..1a385d5642 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -293,9 +293,7 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, let tool = app.session.tool_id(); let Some(st) = app.session.active().filter(|_| matches!(tool, "selection" | "directSelection" | "groupSelection")) else { return false }; // The tool's interaction while it moves (or Alt-copies) the selection. - if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { - return false; - } + let Some(it) = st.interaction.as_ref().filter(|i| i.label == "Move" || i.label == "Copy") else { return false }; if tool != "selection" && st.selection.objects.iter().any(|id| st.selection.partial(*id).is_some() || st.selection.segments.contains_key(id)) { return false; } @@ -305,7 +303,9 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) { return false; } - let ids = st.selection.objects.clone(); + // The art the drag started with: the release rolls the move back to it, and an Alt-copy's + // copies, selected while it previews, go with that. + let ids = it.selection.objects.clone(); egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); true } diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 7f000ee787..2c99c618db 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -181,8 +181,9 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { /// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the /// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the /// left; swapped in a view turned 90°, where the rulers read the other document axis, and the -/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a -/// double-click. +/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags +/// the rulers' origin, snapped as a drawn point is (`Session::ruler_origin_drag`), or resets it on +/// a double-click. pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option { let [top, left, corner] = ruler_rects(full); // Right-click on a ruler or the origin box: the document units, to swap between them as @@ -274,9 +275,17 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c let mut preview = None; if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option, bool)>(drag_id)) { cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)); - let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + let pointer = + corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + // The origin snaps as a drawn point does (Smart Guides, Snap to Point, pixels, the grid). + let at = if cancelled { + app.session.end_ruler_origin_drag(); + None + } else { + app.session.ruler_origin_drag(pointer, app.view_info()) + }; if corner.drag_stopped_by(egui::PointerButton::Primary) { - ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); + end_ruler_origin_drag(app, ui); if !cancelled && let Some(at) = at && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) @@ -284,7 +293,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c app.status(e.to_string()); } } else if !ui.input(|i| i.pointer.primary_down()) { - ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); + end_ruler_origin_drag(app, ui); } else { ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); if !cancelled { @@ -295,6 +304,21 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c preview } +/// The rulers are hidden (View › Rulers, Presentation Mode): a drag of their origin they were +/// showing for is cancelled, so its snapping guides don't stay on the canvas. Nothing previews. +pub(super) fn rulers_hidden(app: &mut VectorcraftApp, ui: &Ui) -> Option { + if ruler_origin_dragging(ui.ctx()) { + end_ruler_origin_drag(app, ui); + } + None +} + +/// Ends a drag from the rulers' corner, released or cancelled: its state and its snapping guides go. +fn end_ruler_origin_drag(app: &mut VectorcraftApp, ui: &Ui) { + ui.data_mut(|d| d.remove::<(Option, bool)>(ruler_origin_drag_id())); + app.session.end_ruler_origin_drag(); +} + /// The key of the ruler origin drag's state in egui's temporary data. fn ruler_origin_drag_id() -> egui::Id { egui::Id::unique("ruler-origin-drag") diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index a7a7f0a45c..4f50944cb3 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -96,11 +96,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else { // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); - // As in the reference app, an image offers Image Trace and Crop Image, and traced art offers - // Expand (). + // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and + // traced art offers Expand (, + // ). if n == 1 && is_image { items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none())); items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none())); + items.push((tl!("Remove Background"), "eraser", crate::addons::image::REMOVE_BACKGROUND, none())); } // Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar // offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does @@ -154,7 +156,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), } // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; - // left out for point type and type in a thread + // left out for point type and type in a thread, which is VectorCraft's own rule // (). if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4f962fdb5d..503178a22b 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"); } @@ -6980,6 +6983,49 @@ fn a_brush_dropped_on_a_path_is_applied_to_it() { assert_eq!(brush(&app).as_deref(), Some("Arrow")); } +#[test] +fn a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, name: &str, at: Point| { + let def = app.session.execute("brush.get", &json!({"name": name})).unwrap(); + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: name.into(), def }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + let undo = app.session.active().unwrap().history.undo.len(); + // A calligraphic brush has no art: nothing. + drop(&mut app, "3 pt. Round", Point::new(200.0, 250.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + // An art brush: a copy of its art, centered where it lands, selected. + drop(&mut app, "Arrow", Point::new(200.0, 250.0)); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Place Brush Art"); + let [placed] = st.selection.objects.as_slice() else { panic!("one object selected: {:?}", st.selection.objects) }; + let b = st.doc.node(*placed).unwrap().geometric_bounds().unwrap(); + assert!((b.center() - Point::new(200.0, 250.0)).hypot() < 0.1, "{b:?}"); + // A scatter brush and a pattern brush (its tiles, in one group) place their art too. + for (i, name) in ["Dots", "Chain"].into_iter().enumerate() { + let at = Point::new(80.0 + 200.0 * i as f64, 60.0); + drop(&mut app, name, at); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 2 + i, "{name}"); + let [placed] = st.selection.objects.as_slice() else { panic!("{name}: one object selected: {:?}", st.selection.objects) }; + let n = st.doc.node(*placed).unwrap(); + assert!((n.geometric_bounds().unwrap().center() - at).hypot() < 0.1, "{name}"); + if name == "Chain" { + assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Side Tile")); + } + } +} + #[test] fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -7163,6 +7209,36 @@ fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { assert_eq!(st.history.undo.len(), undo); } +/// Art Alt-dragged off the canvas (a copy while it moves there) reaches the panels as the art the +/// drag started with, which is what stays when the release over a panel takes the copy back. +#[test] +fn art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let button = |pos: Pos2, pressed: bool| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); + let st = app.session.active().unwrap(); + assert_eq!(st.interaction.as_ref().map(|i| i.label.as_str()), Some("Copy")); + assert!(!st.selection.objects.contains(&id), "the copy is selected while it moves"); + frame(&mut app, &ctx, vec![Event::PointerMoved(pos2(850.0, 100.0))]); + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); + frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *before, "the copy goes, the original stays"); + assert!(st.doc.node(id).is_some()); +} + /// #4233: the Direct and Group Selection tools hand art they move whole to the panels too (Asset /// Export, Graphic Styles…), as the Selection tool does; the move waits and nothing is recorded. #[test] @@ -9277,6 +9353,33 @@ fn the_task_bar_traces_an_image_then_expands_the_trace() { assert_eq!(images, 0, "only the traced paths are kept"); } +/// For a selected image the Contextual Task Bar offers Remove Background, which clears the +/// background in the image's pixels at once (no dialog) with the command's defaults. +#[test] +fn the_task_bar_removes_a_selected_images_background() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let img = + image::RgbaImage::from_fn(20, 20, |x, y| image::Rgba(if (6..14).contains(&x) && (6..14).contains(&y) { [200, 0, 0, 255] } else { [255; 4] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "dot.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let labels = task_bar_labels(&mut app, &ctx); + let at = labels.iter().find(|(t, _)| t == "Remove Background").map(|(_, r)| r.center()).expect("Remove Background is offered"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + assert!(app.ui.dialog.is_none(), "it runs at once"); + let st = app.session.active().unwrap(); + let Some(NodeKind::Image(im)) = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind) else { panic!("an image") }; + let px = image::load_from_memory(&st.doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(10, 10).0[3]), (0, 255), "the white background is cleared, the red dot kept"); +} + /// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with /// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as /// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1765c58e17..513aa54df4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -72,8 +72,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // With the macOS menu bar the menus are at the top of the screen instead. let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) }; // The right-side group fills the space after the menus from the right; when it runs - // short, Discord goes first (it is also under Help), then the search icon (the palette - // stays under its shortcut), then the workspace switcher narrows. + // short, Discord goes first (it is also under Help), then Explore tutorials (also + // Window › Learn), then the search icon (the palette stays under its shortcut), then + // the workspace switcher narrows. let full = ui.max_rect(); let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() }; let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom())); @@ -97,7 +98,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let icon = 28.0; let search = switcher && room >= 64.0 + 8.0 + gap + icon; let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0); - let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false); + // Explore tutorials, beside the search icon, opens the Learn panel. + let learn = search && room >= ws_w + 8.0 + 2.0 * (gap + icon); + let discord = learn && room >= ws_w + 8.0 + 2.0 * (gap + icon) + 10.0 + gap + crate::community::discord_width(ui, false); let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. @@ -127,6 +130,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.palette_query.clear(); } } + if learn { + let shown = crate::panels::learn::visible(&app.ui); + if widgets::icon_button(ui, "graduation-cap", tl!("Explore tutorials"), shown, icon).clicked() + && let Err(e) = app.run("window.learn", json!({})) + { + app.ui.status = e; + } + } if discord { ui.add_space(10.0); crate::community::discord_button(app, ui, false); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9501d9f83b..1e946b56da 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -248,8 +248,9 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { /// Most notes the status message's hover and access information list. const MAX_LISTED_NOTES: usize = 30; -/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when -/// there are several and the status bar still shows the message they came with, else none. +/// The notes behind the status message (an open's, a save's or an export's, +/// [`crate::state::UiState::status_notes`]) when there are several and the status bar still shows +/// the message they came with, else none. fn status_notes(ui: &crate::state::UiState) -> &[String] { let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1); notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default() diff --git a/crates/ui-egui/src/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.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..c25fa2f7ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,6 +18,7 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod brush_replace_art; pub mod bullets_numbering; pub mod char_style_options; pub mod color_balance; @@ -322,8 +323,10 @@ registry! { SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, Confirm: [confirm::KIND] => confirm::SPEC, BrushDelete: [brush_delete::KIND] => brush_delete::SPEC, + BrushReplaceArt: [brush_replace_art::KIND] => brush_replace_art::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, + ReplaceFiles: [confirm::REPLACE_FILES] => confirm::REPLACE_FILES_SPEC, ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, @@ -876,6 +879,9 @@ mod tests_eps; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_hpgl; + #[cfg(test)] mod tests_tiff_bmp_tga; diff --git a/crates/ui-egui/src/dialogs/brush_replace_art.rs b/crates/ui-egui/src/dialogs/brush_replace_art.rs new file mode 100644 index 0000000000..cc1a08b251 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_replace_art.rs @@ -0,0 +1,100 @@ +//! The Brushes panel's question when art Alt-dragged (Option-dragged on macOS) onto a brush that +//! strokes use is to replace its art ([`ask`]): Apply to Strokes, Leave Strokes or Cancel. Apply to +//! Strokes runs `brush.replaceArt` and the strokes redraw with the new art; Leave Strokes runs it +//! with `applyToStrokes: false`, which keeps the brush and its strokes as they were and adds a copy +//! of the brush with the new art. +//! +//! Fields: `name` (the brush), `ids` (the art), `apply` (set by the button pressed) and `__doc` (the +//! uid of the document it asks about). + +use serde_json::{Value, json}; + +use super::{DialogAction, DialogSpec}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets; + +/// The dialog kind of the question. +pub const KIND: &str = "brushReplaceArt"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| { + tl!("Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art?") + .to_string() + }, + body_with_actions: Some(body), + confirm, + ok: None, + max_width: Some(540.0), + ..DialogSpec::FORM +}; + +/// The field that holds the uid of the document the question is about. +const DOC: &str = "__doc"; + +/// Replace the art of brush `name` with the objects `ids`: at once when no stroke uses the brush, +/// else after asking whether the strokes take the new art. Art the brush cannot take (art with no +/// width, say) is refused before anything asks, with the reason in the status bar. +pub fn replace(app: &mut VectorcraftApp, name: &str, ids: &[u64]) { + let params = json!({"name": name, "ids": ids}); + if super::brush_delete::used(app, &[name.to_string()]) != Some(true) { + if let Err(e) = app.run("brush.replaceArt", params) { + app.status(e); + } + return; + } + // A preview on a copy of the document checks the art without changing anything. + if !app.session.in_interaction() && app.session.begin_interaction("Replace Brush Art").is_ok() { + let checked = app.session.preview("brush.replaceArt", ¶ms); + // The interaction began above, on the active document: ending it puts that document back + // as it was, and can fail only when no document is open, when there is nothing to put back. + let _ = app.session.cancel_interaction(); + if let Err(e) = checked { + app.status(e.to_string()); + return; + } + } + ask(app, name, ids); +} + +/// Ask whether the strokes that use brush `name` take the art of the objects `ids`. +pub fn ask(app: &mut VectorcraftApp, name: &str, ids: &[u64]) { + let doc = app.session.active().map(|d| d.uid); + app.ui.dialog = Some(Dialog::new(KIND, json!({"name": name, "ids": ids, "apply": true, DOC: doc}))); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let mut action = DialogAction::None; + ui.horizontal(|ui| { + if widgets::primary_button_enabled(ui, tl!("Apply to Strokes"), true).clicked() { + d.fields.insert("apply".into(), json!(true)); + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Leave Strokes")).clicked() { + d.fields.insert("apply".into(), json!(false)); + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Cancel")).clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +/// Closes before running. When another document has become the active one, it closes as Cancel does +/// and changes no brush. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + if d.fields.get(DOC).and_then(Value::as_u64) != app.session.active().map(|doc| doc.uid) { + return Ok(json!({})); + } + let params = json!({ + "name": d.fields.get("name").cloned().unwrap_or(Value::Null), + "ids": d.fields.get("ids").cloned().unwrap_or_else(|| json!([])), + "applyToStrokes": d.bool("apply"), + }); + app.run("brush.replaceArt", params) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs b/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs new file mode 100644 index 0000000000..f42b7805c2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs @@ -0,0 +1,133 @@ +use egui::{Pos2, Rect, pos2, vec2}; +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::*; + +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, time: f64) -> egui::FullOutput { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let input = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + out +} + +fn press(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) { + frame(ctx, app, vec![], time); + let out = frame(ctx, app, vec![], time + 0.1); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("dialog accessibility tree").nodes; + let button = nodes.iter().find(|(_, node)| node.label().map(str::trim) == Some(label)); + let bounds = button.and_then(|(_, node)| node.bounds()).expect("visible button"); + let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], time + 0.2); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> u64 { + app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() +} + +fn art_height(app: &mut VectorcraftApp, name: &str) -> f64 { + let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()["art"].clone()).unwrap(); + art.geometric_bounds().unwrap().height() +} + +/// An art brush "Bar" (40 × 8 pt) painting a path, and a 40 × 20 pt rectangle: → (path, rectangle). +fn used_brush(app: &mut VectorcraftApp) -> (NodeId, u64) { + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bar = rect(app, 10.0, 10.0, 40.0, 8.0); + app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap(); + let path = NodeId(rect(app, 100.0, 100.0, 80.0, 40.0)); + app.run("brush.apply", json!({"name": "Bar", "ids": [path.0]})).unwrap(); + (path, rect(app, 10.0, 40.0, 40.0, 20.0)) +} + +#[test] +fn an_unused_brush_takes_the_art_without_asking() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bar = rect(&mut app, 10.0, 10.0, 40.0, 8.0); + app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap(); + let tall = rect(&mut app, 10.0, 40.0, 40.0, 20.0); + replace(&mut app, "Bar", &[tall]); + assert!(app.ui.dialog.is_none()); + assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6); +} + +#[test] +fn apply_to_strokes_gives_the_brush_the_new_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (path, tall) = used_brush(&mut app); + replace(&mut app, "Bar", &[tall]); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND), "strokes use Bar: it asks"); + let ctx = context(); + let before = app.session.active().unwrap().history.undo.len(); + press(&ctx, &mut app, "Apply to Strokes", 0.0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6); + let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap(); + assert_eq!(stroke.brush.as_deref(), Some("Bar")); +} + +#[test] +fn leave_strokes_adds_a_copy_and_cancel_changes_nothing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (path, tall) = used_brush(&mut app); + let ctx = context(); + let before = app.session.active().unwrap().history.undo.len(); + replace(&mut app, "Bar", &[tall]); + press(&ctx, &mut app, "Cancel", 0.0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), before); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6); + replace(&mut app, "Bar", &[tall]); + press(&ctx, &mut app, "Leave Strokes", 1.0); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6, "Bar stays as it was"); + assert!((art_height(&mut app, "Bar copy") - 20.0).abs() < 1e-6); + let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap(); + assert_eq!(stroke.brush.as_deref(), Some("Bar")); +} + +/// Art a used brush cannot take is refused before the question: no dialog, the reason in the status +/// bar, and the document and its undo history as they were. +#[test] +fn art_with_no_width_is_refused_before_it_asks() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let _ = used_brush(&mut app); + let flat = app.run("shape.line", json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap(); + let st = app.session.active().unwrap(); + let (doc, steps) = (st.doc.clone(), st.history.undo.len()); + replace(&mut app, "Bar", &[flat]); + assert!(app.ui.dialog.is_none(), "nothing asks"); + assert!(app.ui.status.contains("the selected art has no width"), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *doc); + assert_eq!(st.history.undo.len(), steps); +} + +#[test] +fn a_button_changes_no_brush_after_another_document_became_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (_, tall) = used_brush(&mut app); + let ctx = context(); + replace(&mut app, "Bar", &[tall]); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + press(&ctx, &mut app, "Apply to Strokes", 0.0); + assert!(app.ui.dialog.is_none()); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 06e96e45fe..6f51be44d9 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -63,6 +63,52 @@ pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) { app.ui.dialog = Some(Dialog::new(REPLACE, fields)); } +/// The dialog kind of the question whether a save or export may replace the files it writes beside +/// the one picked in the save panel ([`ask_replace_files`]). Fields: `message`, `detail`, `files` +/// (their paths), `allowed` (files an earlier OK allowed), `doc` (the document's uid), `__command` +/// and `__params`. +pub const REPLACE_FILES: &str = "replaceFiles"; + +pub(super) const REPLACE_FILES_SPEC: DialogSpec = DialogSpec { confirm: replace_files, ..SPEC }; + +/// Ask whether to replace `files`, files that are there beside the one a save or export of the +/// document `doc` (its uid) writes, which the save panel did not ask about (a file per artboard, a +/// linked image). OK runs `command` with `params` again on that document, now replacing those +/// files and `allowed` (those an earlier OK allowed); Cancel writes nothing. +pub(crate) fn ask_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: Value) { + let name = files.first().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).unwrap_or_default(); + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", &name)]); + // Several: every one of them, by name. + let detail = match files { + [_, _, ..] => files.iter().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).collect::>().join("\n"), + _ => String::new(), + }; + let fields = + json!({"message": message, "detail": detail, "files": files, "allowed": allowed, "doc": doc, "__command": command, "__params": params}); + let d = Dialog::new(REPLACE_FILES, fields); + // Asked when a Background Save or Export ends: a dialog open then stays, and this waits its turn + // (however many messages wait: the save or export needs the answer). + match app.ui.dialog { + None => app.ui.dialog = Some(d), + Some(_) => app.ui.told.push(d), + } +} + +/// OK on [`ask_replace_files`]: what asked runs again on its document, and the files the question +/// named are replaced; one it writes now that was not named is asked about again. The save or export +/// reads the allowance when it starts, before a background one hands its work to the worker. +fn replace_files(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + if !crate::picks::on_document(app, d.fields.get("doc").and_then(Value::as_u64)) { + return Ok(Value::Null); + } + let paths = |k: &str| d.fields.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string); + app.picks.replace_files_ok = paths("allowed").chain(paths("files")).collect(); + let r = confirm(app, d); + app.picks.replace_files_ok.clear(); + r +} + /// The dialog kind of the question Reset Preferences asks over the Preferences dialog /// ([`ask_reset_prefs`]). Fields: `message`, `detail`. pub const RESET_PREFS: &str = "resetPreferences"; diff --git a/crates/ui-egui/src/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/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 3a58ee3a9e..caa9dfdc91 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -382,25 +382,28 @@ fn choices(effect: &str, key: &str) -> Option { /// The parameters whose dialogs list their values in their own bodies, as (label, value), each /// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI -/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d" -/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's +/// language: the edge of both Revolves, the bevel of both Extrude & Bevels (Rounded translated in +/// the "3d" context first), the surface and shading color of the classic 3D effects, every Warp style's /// orientation, Rasterize's Color Model (its `document` value labeled /// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and /// the channel of Levels and Curves. pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option> { let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s); Some(match (effect, key) { - (vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], + (vectorcraft_effects::REVOLVE | vectorcraft_effects::CLASSIC_REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], ( vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE, "shadowPosition", ) => { vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")] } - (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "surface") => { vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] } - (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")], + (vectorcraft_effects::CLASSIC_ROTATE, "surface") => vec![(tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse")], + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE | vectorcraft_effects::CLASSIC_ROTATE, "shadingColor") => { + vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")] + } (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ (tl!("None"), "none"), (tl!("Classic"), "classic"), @@ -965,6 +968,11 @@ mod tests { (vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"), (vectorcraft_effects::CLASSIC_EXTRUDE, "surface"), (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"), + (vectorcraft_effects::CLASSIC_REVOLVE, "edge"), + (vectorcraft_effects::CLASSIC_REVOLVE, "surface"), + (vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor"), + (vectorcraft_effects::CLASSIC_ROTATE, "surface"), + (vectorcraft_effects::CLASSIC_ROTATE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/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/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs index 91731006f1..4978a6a1f6 100644 --- a/crates/ui-egui/src/dialogs/new_text_style.rs +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -40,6 +40,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result. use serde_json::{Map, Value, json}; -use vectorcraft_doc::{HyphenationOptions, JustificationOptions, Unit}; +use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL; +use vectorcraft_doc::{HyphenationOptions, JustificationOptions, TabAlign, TabStop, Unit}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -21,7 +24,11 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 5] = ["general", "basic", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 7] = ["general", "basic", "tabs", "composition", "hyphenation", "justification", "eastAsian"]; +/// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's +/// order. +const TAB_ALIGNS: [(TabAlign, &str); 4] = + [(TabAlign::Left, "left"), (TabAlign::Center, "center"), (TabAlign::Right, "right"), (TabAlign::Decimal, "decimal")]; /// The distance fields of Basic Paragraph Formats, which are also the attributes they set. const DISTANCES: [&str; 5] = ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"]; /// The range of the indents, the space before and after, and the Hyphenation Zone (pt), as @@ -98,6 +105,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "korean_line_break_anywhere", + "tabs", "section", ] }, @@ -112,6 +121,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { let doc = &app.session.active().ok_or("no document open")?.doc; let def = doc.para_styles.iter().find(|s| s.name == name).ok_or_else(|| format!("no style `{name}`"))?; let mut fields = form_fields(&def.attrs, unit); + fields.insert("__vertical".into(), json!(vertical_text(app))); fields.insert("name".into(), json!(name)); fields.insert("__style".into(), json!(name)); fields.insert("section".into(), json!("general")); @@ -129,6 +139,76 @@ pub(crate) fn form_fields(attrs: &Map, unit: Unit) -> Map bool { + crate::panels::character::first_selected_text(app).is_some_and(|t| t.vertical) +} + +/// The `tabs` field for a style's `tabs` attribute: unset (null) when the style leaves it out, +/// otherwise one entry per stop ([`stop_field`]). +fn tabs_field(value: Option<&Value>, unit: Unit) -> Value { + let Some(stops) = value.and_then(Value::as_array) else { return Value::Null }; + Value::Array(stops.iter().filter_map(|s| serde_json::from_value::(s.clone()).ok()).map(|s| stop_field(&s, unit)).collect()) +} + +/// A tab stop's entry in the `tabs` field: its position as text in `unit`, its alignment, leader +/// and Align On character, and the position in points it shows (`__pt`), which a position still as +/// it shows keeps exactly. +fn stop_field(s: &TabStop, unit: Unit) -> Value { + let align = TAB_ALIGNS.iter().find(|(a, _)| *a == s.align).map_or("left", |(_, key)| *key); + json!({"position": unit.format(s.position), "align": align, "leader": s.leader, "alignOn": s.align_on.to_string(), "__pt": s.position}) +} + +/// The position in points of a `tabs` entry: its `__pt` while its text is as that shows in `unit`, +/// else the text read in `unit`; `None` when the text is not a distance. +fn stop_position(row: &Value, unit: Unit) -> Option { + let text = row.get("position").and_then(Value::as_str).map_or("", str::trim); + row.get("__pt").and_then(Value::as_f64).filter(|pt| pt.is_finite() && unit.format(*pt) == text).or_else(|| unit.parse(text)) +} + +/// The tab stop a `tabs` entry sets, or why it sets none: its position must be a distance from 0 to +/// 100000 pt (`label` names the field), its alignment one of [`TAB_ALIGNS`], its leader text of at +/// most eight characters and its Align On character one character. A leader or Align On character +/// left out is none or a period. +fn read_stop(row: &Value, unit: Unit, label: &str) -> Result { + let position = stop_position(row, unit).ok_or_else(|| format!("{label} must be a number"))?; + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { + let (min, max) = (unit.format(0.0), unit.format(TabStop::MAX_POSITION)); + return Err(format!("{label} must be a number from {min} to {max}")); + } + let other = match row.get("align") { + None | Some(Value::Null) => "left", + Some(v) => v.as_str().unwrap_or(""), + }; + let align = TAB_ALIGNS.iter().find(|(_, key)| *key == other).map(|(a, _)| *a).ok_or_else(|| format!("unknown align `{other}`"))?; + let leader = match row.get("leader") { + None | Some(Value::Null) => "", + Some(Value::String(s)) => s.as_str(), + Some(_) => { + let key = "leader"; + return Err(format!("`{key}` must be a string")); + } + }; + let (label, max) = (caption(tl!("Leader:")), TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(format!("{label} must be at most {max} characters")); + } + let label = caption(tl!("Align On:")); + let align_on = match row.get("alignOn") { + None | Some(Value::Null) => '.', + Some(Value::String(s)) => { + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => c, + _ => return Err(format!("{label} must be one character")), + } + } + Some(_) => return Err(format!("{label} must be one character")), + }; + Ok(TabStop { position, align, leader: leader.into(), align_on }) +} + /// A distance in points as its field shows it in `unit`. fn distance_field(value: Option<&Value>, unit: Unit) -> Value { value.and_then(Value::as_f64).map_or_else(|| json!(""), |pt| json!(unit.format(pt))) @@ -154,7 +234,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map, unit: Unit) -> Map, unit: Unit) -> Map Result, String> { let same = JUSTIFICATION_FIELDS.iter().all(|key| unchanged(key, "__resetJustification")); let value = if same { None } else { justification(d, &original, &original_fields)? }; keep_or(&mut attrs, "justification", same, value); + + let same = unchanged("tabs", "__resetTabs"); + let value = if same { None } else { tab_stops(d, &original, unit)? }; + keep_or(&mut attrs, "tabs", same, value); + + let same = unchanged("korean_line_break_anywhere", "__resetEastAsian"); + keep_or(&mut attrs, "korean_line_break_anywhere", same, switch("korean_line_break_anywhere")); Ok(attrs) } +/// The `tabs` attribute the Tabs section sets: none while the list is unset; otherwise its stops +/// sorted by position. Of stops within 1e-6 pt of each other, the one edited last (`__tabEdited`) +/// is kept, else the first in the list. A stop as the style has it is kept without a check, and any +/// other is read by [`read_stop`]. An error when the list has more than 256 stops and more than the +/// style has. +fn tab_stops(d: &Dialog, original: &Map, unit: Unit) -> Result, String> { + let rows = match d.fields.get("tabs") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(rows)) => rows, + Some(_) => { + let key = "tabs"; + return Err(format!("`{key}` must be an array")); + } + }; + let kept: Vec = + original.get("tabs").and_then(Value::as_array).into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect(); + let max = TabStop::MAX_STOPS; + if rows.len() > max.max(kept.len()) { + return Err(format!("at most {max} tab stops")); + } + let label = caption(if d.bool("__vertical") { tl!("Y:") } else { tl!("X:") }); + let kept_rows: Vec = kept.iter().map(|s| stop_field(s, unit)).collect(); + let mut stops = Vec::with_capacity(rows.len()); + for row in rows { + match kept_rows.iter().position(|k| k == row) { + Some(i) => stops.push(kept[i].clone()), + None => stops.push(read_stop(row, unit, label)?), + } + } + let edited = d.fields.get("__tabEdited").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let mut order: Vec = (0..stops.len()).collect(); + order.sort_by(|&a, &b| stops[a].position.total_cmp(&stops[b].position).then(a.cmp(&b))); + let mut keep: Vec = Vec::with_capacity(order.len()); + for i in order { + match keep.last_mut() { + Some(last) if (stops[*last].position - stops[i].position).abs() < 1e-6 => { + if Some(i) == edited { + *last = i; + } + } + _ => keep.push(i), + } + } + let stops: Vec = keep.into_iter().map(|i| stops[i].clone()).collect(); + Ok(Some(serde_json::to_value(stops).map_err(|e| e.to_string())?)) +} + /// The `hyphenation` attribute the Hyphenation fields set: none when every field is empty or /// unset; otherwise the style's options (or the defaults) with each changed field written over /// them, an empty one at its default. @@ -416,18 +551,34 @@ fn reset_justification(d: &mut Dialog) { } } +/// Reset Panel in Tabs: the tab stops become unset, which leaves `tabs` out of the style. +fn reset_tabs(d: &mut Dialog) { + d.fields.insert("__resetTabs".into(), json!(true)); + d.fields.insert("tabs".into(), Value::Null); + d.fields.remove("__tabEdited"); + d.fields.insert("__tabSel".into(), json!(0)); +} + +/// Reset Panel in East Asian Composition Settings: Korean Line Break Anywhere becomes unset. +fn reset_east_asian(d: &mut Dialog) { + d.fields.insert("__resetEastAsian".into(), json!(true)); + d.fields.insert("korean_line_break_anywhere".into(), Value::Null); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "tabs", "composition", "hyphenation", "justification", "eastAsian"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. pub(crate) fn reset_section(d: &mut Dialog) { match d.str("section").as_str() { "basic" => reset_basic(d), + "tabs" => reset_tabs(d), "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), + "eastAsian" => reset_east_asian(d), _ => {} } } @@ -449,9 +600,16 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - for (id, label) in - SECTIONS.into_iter().zip([tl!("General"), tl!("Basic Paragraph Formats"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]) - { + let labels = [ + tl!("General"), + tl!("Basic Paragraph Formats"), + tl!("Tabs"), + tl!("Composition"), + tl!("Hyphenation"), + tl!("Justification"), + tl!("East Asian Composition Settings"), + ]; + for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); } @@ -529,6 +687,156 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The editing fields of the selected tab stop, with the `tabs` entry key each one shows. +const TAB_EDITORS: [(&str, &str); 3] = [("__tabPosition", "position"), ("__tabLeader", "leader"), ("__tabAlignOn", "alignOn")]; + +/// Tabs: the selected stop's Alignment, X (Y in vertical type), Leader and Align On, which only a +/// decimal stop uses; Add ([`add_tab`]), Delete Tab ([`delete_tab`]) and Clear All Tabs +/// ([`clear_tabs`]); and the list of stops, one row each, a click on which selects its stop, which +/// scrolls when it is taller than 160 points. A change to a field makes its stop the one edited last. +pub(crate) fn tabs_body(ui: &mut egui::Ui, d: &mut Dialog) { + let vertical = d.bool("__vertical"); + let (mut rows, mut sel, _) = tab_rows(d); + let mut changed = false; + let has = sel < rows.len(); + let shown = TAB_EDITORS.map(|(_, key)| rows.get(sel).and_then(|r| r.get(key)).and_then(Value::as_str).unwrap_or("").to_string()); + for ((field, _), text) in TAB_EDITORS.iter().zip(&shown) { + d.fields.insert((*field).into(), json!(text)); + } + let align = rows.get(sel).and_then(|r| r.get("align")).and_then(Value::as_str).unwrap_or("left").to_string(); + egui::Grid::new("paragraph-style-tabs").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + let row = ui.label(tl!("Alignment:")); + let names: Vec<&str> = TAB_ALIGNS.iter().map(|(a, _)| crate::panels::tabs::align_name(*a, vertical)).collect(); + let shown = TAB_ALIGNS.iter().position(|(_, key)| *key == align).and_then(|i| names.get(i).copied()).unwrap_or(""); + ui.add_enabled_ui(has, |ui| { + ui.label_inputs_by(row.id, |ui| { + if let Some(i) = crate::widgets::dropdown(ui, (KIND, "tab_align"), shown, &names, 190.0) + && let (Some(r), Some((_, key))) = (rows.get_mut(sel).and_then(Value::as_object_mut), TAB_ALIGNS.get(i)) + { + r.insert("align".into(), json!(key)); + changed = true; + } + }); + }); + ui.end_row(); + field_row(ui, d, "__tabPosition", if vertical { tl!("Y:") } else { tl!("X:") }, "", has); + field_row(ui, d, "__tabLeader", tl!("Leader:"), "", has); + field_row(ui, d, "__tabAlignOn", tl!("Align On:"), "", has && align == "decimal"); + }); + for ((field, key), was) in TAB_EDITORS.iter().zip(&shown) { + let typed = d.str(field); + if let Some(r) = rows.get_mut(sel).and_then(Value::as_object_mut) + && typed != *was + { + r.insert((*key).into(), json!(typed)); + changed = true; + } + } + if changed { + d.fields.insert("tabs".into(), Value::Array(rows.clone())); + d.fields.insert("__tabEdited".into(), json!(sel)); + } + ui.add_space(6.0); + let (mut add, mut delete, mut clear) = (false, false, false); + let can_add = new_tab_position(&rows, unit_of(d)).is_some(); + ui.horizontal(|ui| { + add = ui.add_enabled(can_add, egui::Button::new(tl!("Add"))).clicked(); + delete = ui.add_enabled(has, egui::Button::new(tl!("Delete Tab"))).clicked(); + clear = ui.add_enabled(!rows.is_empty(), egui::Button::new(tl!("Clear All Tabs"))).clicked(); + }); + ui.add_space(4.0); + let reveal = d.fields.remove("__tabScroll").is_some(); + egui::ScrollArea::vertical().id_salt("paragraph-style-tab-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let a = r.get("align").and_then(Value::as_str).and_then(|key| TAB_ALIGNS.iter().find(|(_, k)| *k == key)).map(|(a, _)| *a); + let name = a.map_or("", |a| crate::panels::tabs::align_name(a, vertical)); + let mut text = format!("{} {name}", r.get("position").and_then(Value::as_str).unwrap_or("")); + if let Some(leader) = r.get("leader").and_then(Value::as_str).filter(|l| !l.is_empty()) { + text += &format!(" {leader}"); + } + let row = ui.selectable_label(i == sel, text); + if reveal && i == sel { + ui.scroll_to_rect_animation(row.rect, None, egui::style::ScrollAnimation::none()); + } + if row.clicked() { + sel = i; + } + } + }); + d.fields.insert("__tabSel".into(), json!(sel)); + if add { + add_tab(d); + } else if delete { + delete_tab(d); + } else if clear { + clear_tabs(d); + } +} + +/// The `tabs` entries of `d` (none while unset), the selected one's index (`__tabSel`, at most the +/// last) and the index of the one edited last (`__tabEdited`). +fn tab_rows(d: &Dialog) -> (Vec, usize, Option) { + let rows: Vec = d.fields.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default(); + let index = |key: &str| d.fields.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let sel = index("__tabSel").unwrap_or(0).min(rows.len().saturating_sub(1)); + (rows, sel, index("__tabEdited")) +} + +/// The position (pt) Add gives a new stop among `rows`: half an inch past the furthest stop that +/// reads, half an inch when none does, and at most 100000 pt. `None` while the list holds 256 stops +/// or when a stop already sits at that position. +fn new_tab_position(rows: &[Value], unit: Unit) -> Option { + if rows.len() >= TabStop::MAX_STOPS { + return None; + } + let positions: Vec = rows.iter().filter_map(|r| stop_position(r, unit)).collect(); + let furthest = positions.iter().copied().filter(|pt| (0.0..=TabStop::MAX_POSITION).contains(pt)).reduce(f64::max); + let position = furthest.map_or(DEFAULT_TAB_INTERVAL, |pt| pt + DEFAULT_TAB_INTERVAL).min(TabStop::MAX_POSITION); + (!positions.iter().any(|pt| (pt - position).abs() < 1e-6)).then_some(position) +} + +/// Add in Tabs: a left stop at [`new_tab_position`], which becomes the selected and last edited +/// stop and is scrolled into view in the list. Nothing when that gives no position. +pub(crate) fn add_tab(d: &mut Dialog) { + let unit = unit_of(d); + let (mut rows, _, _) = tab_rows(d); + let Some(position) = new_tab_position(&rows, unit) else { return }; + rows.push(stop_field(&TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: '.' }, unit)); + let at = rows.len() - 1; + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabSel".into(), json!(at)); + d.fields.insert("__tabEdited".into(), json!(at)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Delete Tab in Tabs: remove the selected stop; the next one (or the last) becomes selected. +pub(crate) fn delete_tab(d: &mut Dialog) { + let (mut rows, sel, edited) = tab_rows(d); + if sel >= rows.len() { + return; + } + rows.remove(sel); + match edited { + Some(e) if e == sel => { + d.fields.remove("__tabEdited"); + } + Some(e) if e > sel => { + d.fields.insert("__tabEdited".into(), json!(e - 1)); + } + _ => {} + } + d.fields.insert("__tabSel".into(), json!(sel.min(rows.len().saturating_sub(1)))); + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Clear All Tabs in Tabs: an empty list of stops, which the style then sets. +pub(crate) fn clear_tabs(d: &mut Dialog) { + d.fields.insert("tabs".into(), json!([])); + d.fields.insert("__tabSel".into(), json!(0)); + d.fields.remove("__tabEdited"); +} + /// Composition: the Single-line or Every-line Composer, and Roman Hanging Punctuation. pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("paragraph-style-composition").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { @@ -597,6 +905,14 @@ pub(crate) fn justification_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// East Asian Composition Settings: Korean Composition Settings › Korean Line Break Anywhere. +pub(crate) fn east_asian_body(ui: &mut egui::Ui, d: &mut Dialog) { + ui.strong(tl!("Korean Composition Settings")); + egui::Grid::new("paragraph-style-east-asian").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + switch(ui, d, "korean_line_break_anywhere", tl!("Korean Line Break Anywhere"), true); + }); +} + /// The heading of a spacing column, for the accessible name of its field. fn column_name(column: &str) -> &'static str { match column { @@ -624,9 +940,11 @@ pub(crate) fn general_body(ui: &mut egui::Ui, d: &mut Dialog) { pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { match d.str("section").as_str() { "basic" => basic_body(ui, d), + "tabs" => tabs_body(ui, d), "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), + "eastAsian" => east_asian_body(ui, d), _ => general_body(ui, d), } } diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 42fa0bd491..467b0dc1ae 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -157,14 +157,17 @@ fn invalid_values_keep_the_dialog_open() { fn reset_panel_clears_only_its_section() { let mut app = app(); let all = json!({"justify": "Center", "space_after": 6.0, "composer": "singleLine", "roman_hanging": true, "hyphenate": true, - "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}]}); + "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}], + "korean_line_break_anywhere": true}); app.run("paraStyle.new", json!({"name": "Body", "attrs": all})).unwrap(); let stored = def(&app, "Body"); for (section, cleared) in [ ("basic", &["justify", "space_after"][..]), + ("tabs", &["tabs"]), ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), + ("eastAsian", &["korean_line_break_anywhere"]), ] { app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); assert!(app.run("ui.paraStyleOptions.resetPanel", json!({})).is_err(), "General has no Reset Panel"); @@ -239,7 +242,15 @@ fn new_paragraph_style_offers_the_sections() { set(&mut app, "section", json!(section)); texts += &crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); } - for label in ["Basic Paragraph Formats", "Composition", "First-line Left Indent", "Hyphenate Capitalized Words", "Single Word Justification:"] { + for label in [ + "Basic Paragraph Formats", + "Tabs", + "Composition", + "First-line Left Indent", + "Align On:", + "Hyphenate Capitalized Words", + "Single Word Justification:", + ] { assert!(texts.contains(label), "{label} is drawn: {texts}"); } set(&mut app, "section", json!("basic")); @@ -257,6 +268,255 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// East Asian Composition Settings › Korean Line Break Anywhere sets the style's attribute, which +/// its paragraphs take; unset, the style leaves it out and the paragraphs keep their own. +#[test] +fn korean_line_break_anywhere_is_set_by_the_east_asian_section() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "가나 다라"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + assert!(!paras(&app, id)[0].korean_line_break_anywhere); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["korean_line_break_anywhere"].is_null()); + set(&mut app, "section", json!("eastAsian")); + set(&mut app, "korean_line_break_anywhere", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("korean_line_break_anywhere"), Some(&json!(true))); + assert!(paras(&app, id)[0].korean_line_break_anywhere, "the style's paragraphs take it"); +} + +/// The rows of the `tabs` field of the open dialog. +fn tab_rows(app: &VectorcraftApp) -> Vec { + app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() +} + +/// Tabs shows a style's stops in the General unit with their alignment, leader and Align On +/// character; a stop left as it was keeps its position exactly, a changed one is read in the unit, +/// and the stops are stored sorted by position. Add puts a left stop half an inch past the furthest +/// stop, Delete Tab removes the selected stop and Clear All Tabs leaves the style an empty list. +#[test] +fn tabs_section_edits_the_style_stops() { + let mut app = app(); + let stops = json!([{"position": 36.0, "align": "right"}, {"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"left_indent": 3.0, "tabs": stops}})).unwrap(); + let stored = def(&app, "Body"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = tab_rows(&app); + let shown: Vec<(&str, &str, &str, &str)> = rows + .iter() + .map(|r| (r["position"].as_str().unwrap(), r["align"].as_str().unwrap(), r["leader"].as_str().unwrap(), r["alignOn"].as_str().unwrap())) + .collect(); + assert_eq!(shown, [("36 pt", "right", "", "."), ("72.123 pt", "decimal", ".", ",")]); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), stored, "nothing changed"); + + // The first stop moves past the second and turns centered; the second keeps its exact position. + let mut rows = rows; + rows[0]["position"] = json!("1.5 in"); + rows[0]["align"] = json!("center"); + set(&mut app, "tabs", Value::from(rows)); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!( + a["tabs"], + json!([{"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}, {"position": 108.0, "align": "center", "alignOn": "."}]) + ); + assert_eq!(a["left_indent"], json!(3.0), "other attributes stay"); + + let d = app.ui.dialog.as_mut().unwrap(); + add_tab(d); + assert_eq!((tab_rows(&app).len(), app.ui.dialog.as_ref().unwrap().fields["__tabSel"].clone()), (3, json!(2))); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"][2], json!({"position": 144.0, "align": "left", "alignOn": "."}), "half an inch past the furthest"); + set(&mut app, "__tabSel", json!(0)); + delete_tab(app.ui.dialog.as_mut().unwrap()); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"].as_array().unwrap().iter().map(|s| s["position"].clone()).collect::>(), [json!(72.123456), json!(144.0)]); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"].as_array().map(Vec::len), Some(2)); + + // Clear All Tabs leaves an empty list set, which Reset Panel removes; a new stop on a style + // without stops goes at half an inch. + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + clear_tabs(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([])); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"], json!([]), "an empty list is stored"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + app.run("ui.paraStyleOptions.resetPanel", json!({})).unwrap(); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert!(!a.contains_key("tabs") && a["left_indent"] == json!(3.0), "{a:?}"); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([{"position": 36.0, "align": "left", "alignOn": "."}])); +} + +/// Of stops typed at one position, the one edited last is kept; with none edited, the first in the +/// list is. +#[test] +fn a_repeated_tab_position_keeps_the_stop_edited_last() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = json!([{"position": "36", "align": "right"}, {"position": "0.5 in", "align": "center"}, {"position": "10", "align": "left"}]); + set(&mut app, "tabs", rows); + let kept = |app: &VectorcraftApp| edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"].clone(); + assert_eq!(kept(&app), json!([{"position": 10.0, "align": "left", "alignOn": "."}, {"position": 36.0, "align": "right", "alignOn": "."}])); + set(&mut app, "__tabEdited", json!(1)); + assert_eq!(kept(&app)[1]["align"], json!("center")); +} + +/// The list of stops scrolls, so a style with 256 stops keeps the Tabs section short enough for the +/// dialog's buttons to stay on screen. +#[test] +fn a_long_tab_list_scrolls() { + let mut app = app(); + let stops: Vec = (0..256).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut height = f32::INFINITY; + let mut out = ctx.run_ui(Default::default(), |ui| { + let top = ui.cursor().top(); + tabs_body(ui, d); + height = ui.cursor().top() - top; + }); + out.textures_delta.clear(); + assert!(height < 400.0, "the Tabs section is {height} points tall"); +} + +/// The stop Add puts at the end of a long list is scrolled into view: its row in the list is drawn +/// inside the list's visible area. +#[test] +fn an_added_tab_stop_is_scrolled_into_view() { + let mut app = app(); + let stops: Vec = (0..100).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |d: &mut Dialog| { + let mut out = ctx.run_ui(Default::default(), |ui| tabs_body(ui, d)); + out.textures_delta.clear(); + out.shapes.iter().any(|c| match &c.shape { + egui::Shape::Text(t) => t.galley.text().starts_with("3636 pt ") && c.clip_rect.intersects(t.visual_bounding_rect()), + _ => false, + }) + }; + draw(d); + add_tab(d); + let shown: Vec = (0..3).map(|_| draw(d)).collect(); + assert_eq!(shown.last(), Some(&true), "{shown:?}"); +} + +/// Add does not put a stop where one already is: with a stop at 100000 pt, the largest position, +/// it adds nothing and the Add button is disabled, so the existing stop keeps its settings. +#[test] +fn add_leaves_a_stop_at_the_largest_position_alone() { + let mut app = app(); + let stops = json!([{"position": 100000.0, "align": "right", "leader": "-"}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let before = tab_rows(&app); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(tab_rows(&app), before, "no stop is added"); + assert!(new_tab_position(&before, Unit::Points).is_none(), "Add is disabled"); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), def(&app, "Body"), "the stop is kept"); +} + +/// A tab position out of range or not a distance, an unknown alignment, a leader of more than eight +/// characters, an Align On entry that is not one character, more than 256 stops, a `tabs` value that +/// is not a list and a stop that is not an object are errors that keep the dialog open and the style +/// as it was. Vertical type names the position Y. +#[test] +fn invalid_tab_stops_keep_the_dialog_open() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + let stop = |key: &str, value: Value| { + let mut stop = json!({"position": "36", "align": "decimal", "leader": "", "alignOn": "."}); + stop[key] = value; + json!([stop]) + }; + let many = Value::from((0..257).map(|i| json!({"position": format!("{i}"), "align": "left"})).collect::>()); + for (tabs, why) in [ + (stop("position", json!("-1")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("100001 pt")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("far")), "X must be a number"), + (stop("align", json!("diagonal")), "unknown align `diagonal`"), + (stop("leader", json!("123456789")), "Leader must be at most 8 characters"), + (stop("alignOn", json!("")), "Align On must be one character"), + (stop("alignOn", json!(",.")), "Align On must be one character"), + (many, "at most 256 tab stops"), + (json!("36"), "`tabs` must be an array"), + (json!({"position": "36"}), "`tabs` must be an array"), + (json!([5]), "X must be a number"), + ] { + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", tabs.clone()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), why, "{tabs}"); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + super::super::cancel(&mut app); + assert_eq!(def(&app, "Body"), Map::new()); + } + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "__vertical", json!(true)); + set(&mut app, "tabs", stop("position", json!("far"))); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), "Y must be a number"); + // Stops a style read from a file holds out of range stay while another stop is added. + super::super::cancel(&mut app); + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let held = json!([{"position": 200000.0, "align": "left", "leader": "123456789", "alignOn": "."}]); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), held.clone()); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + add_tab(app.ui.dialog.as_mut().unwrap()); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"][1], held[0]); +} + +/// Preview shows a style's new stops on the paragraphs that use it, and Cancel puts the document +/// and its history back. OK sets them in one undo step, a paragraph whose stops the Tabs panel set +/// keeps its own, and Undo and Redo restore both. Saving and opening the document keeps the stops. +#[test] +fn tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": [{"position": 36.0}]}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + app.run("text.tabs.set", json!({"ids": [id], "start": 4, "end": 4, "stops": [{"position": 50.0}]})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let new_stops = json!([{"position": "20", "align": "right", "leader": "."}, {"position": "80", "align": "decimal", "alignOn": ","}]); + let positions = |app: &VectorcraftApp| paras(app, id).iter().map(|p| p.tabs.iter().map(|t| t.position).collect::>()).collect::>(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + set(&mut app, "tabs", new_stops.clone()); + frame(&mut app); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]], "Preview shows the change"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", new_stops); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]]); + let p = paras(&app, id); + assert_eq!((p[0].tabs[0].leader.as_str(), p[0].tabs[1].align_on), (".", ',')); + let changed = app.session.doc().unwrap().doc.clone(); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&changed, false)).unwrap(); + let def_of = |d: &vectorcraft_doc::Document| d.para_styles.iter().find(|s| s.name == "Body").map(|s| s.attrs.clone()); + assert_eq!(def_of(&saved), def_of(&changed)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, changed); +} + /// The section list, Reset Panel and both buttons are drawn in every section, and the Hyphenation /// options are drawn whether or not Hyphenation is on. #[test] @@ -269,9 +529,11 @@ fn every_section_draws_its_fields() { for (section, label) in [ ("general", "Style Name:"), ("basic", "Space After Paragraph"), + ("tabs", "Clear All Tabs"), ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), + ("eastAsian", "Korean Line Break Anywhere"), ] { set(&mut app, "section", json!(section)); let mut texts = String::new(); @@ -282,7 +544,7 @@ fn every_section_draws_its_fields() { out.textures_delta.clear(); texts = crate::tests_labels::shapes_text(&out); } - for shown in [label, "General", "Basic Paragraph Formats", "Justification", "Reset Panel", "OK", "Cancel"] { + for shown in [label, "General", "Basic Paragraph Formats", "Tabs", "Justification", "Reset Panel", "OK", "Cancel"] { assert!(texts.contains(shown), "{shown} is drawn in {section}: {texts}"); } } diff --git a/crates/ui-egui/src/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/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index b0cac0c011..c7a306887e 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,12 +1,12 @@ //! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, -//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the -//! rotation and light gizmos live on the artboard. All document changes run through the effect +//! and Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), whose own controls are in +//! `three_d_classic`. The object and the rotation and light gizmos live on the artboard. All document changes run through the effect //! dialog's existing preview/commit commands. use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; use crate::state::Dialog; use crate::theme::Tokens; @@ -145,11 +145,12 @@ pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) } -/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs -/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on -/// Lighting. +/// Extrude & Bevel, Inflate, Extrude & Bevel (Classic) and Revolve (Classic): Object, Rotation and +/// Lighting tabs (Rotate and Rotate (Classic) have no Object tab). The rotation gizmo shows on the +/// first two, the light gizmo on Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE; + let classic = vectorcraft_effects::is_classic_three_d(id); + let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); @@ -169,7 +170,7 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { - if id == CLASSIC_EXTRUDE { + if classic { super::three_d_classic::surface_controls(ui, d); } else { lighting_controls(ui, d); @@ -178,6 +179,8 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & let unit = app.session.general_unit(); if id == CLASSIC_EXTRUDE { super::three_d_classic::object_controls(ui, d, unit); + } else if id == CLASSIC_REVOLVE { + super::three_d_classic::revolve_controls(ui, d, unit); } else if id == EXTRUDE { extrude_controls(ui, d, unit); } else { diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index ba11d8666c..fd802f83f0 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -379,6 +379,87 @@ fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() { assert_eq!(h.app.session.doc().unwrap().doc, before); } +/// Revolve (Classic): its angle, cap, offset and axis side on the Object tab, Position on the +/// Rotation tab, and the classic surface on the Lighting tab, without Draw Hidden Faces; OK keeps +/// the edit as one undo step. +#[test] +fn classic_revolve_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_REVOLVE; + let mut h = Harness::with("M100 40 L130 60 L130 140 L100 160", CLASSIC_REVOLVE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Angle", "Cap", "Offset", "Axis side", "Left"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Segments" || t == "Revolve angle" || t == "Depth"), "the other effects' options stay out"); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation")); + h.settle(); + for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + h.click(h.rect("Lighting").center()); + h.settle(); + for text in ["Surface", "Plastic", "Light Intensity", "Ambient Light", "Highlight Size", "Blend Steps", "Shading Color"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Draw Hidden Faces"), "every face is drawn"); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("edge".into(), json!("right")); + d.fields.insert("offset".into(), json!(12.0)); + d.fields.insert("surface".into(), json!("wireframe")); + h.settle(); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_REVOLVE); + assert_eq!( + (fx.params["edge"].clone(), fx.params["offset"].as_f64(), fx.params["surface"].clone()), + (json!("right"), Some(12.0), json!("wireframe")) + ); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +/// Rotate (Classic): Position on the Rotation tab, with no Object tab, and No Shading or Diffuse +/// on the Lighting tab, without the highlight or Draw Hidden Faces; OK keeps the edit as one undo +/// step. +#[test] +fn classic_rotate_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_ROTATE; + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_ROTATE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Rotation", "Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Object" || t == "Depth" || t == "Angle"), "no Object tab"); + h.click(h.rect("Lighting").center()); + h.settle(); + for text in ["Surface", "Diffuse", "Light Intensity", "Ambient Light", "Blend Steps", "Shading Color"] { + h.rect(text); + } + assert!( + !h.texts.iter().any(|(t, _)| t == "Highlight Intensity" || t == "Highlight Size" || t == "Draw Hidden Faces"), + "no highlight and no hidden faces" + ); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("surface".into(), json!("none")); + d.fields.insert("rotationY".into(), json!(40.0)); + h.settle(); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_ROTATE); + assert_eq!((fx.params["surface"].clone(), fx.params["rotationY"].as_f64()), (json!("none"), Some(40.0))); + assert!(fx.params.get("highlightSize").is_none() && fx.params.get("drawHiddenFaces").is_none()); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 6ef51d38ed..5016eacd06 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -1,9 +1,9 @@ //! Artboard rotation and light controls for the active 3D effect dialog (Extrude & Bevel, Revolve, -//! Inflate, Rotate). Gestures edit fields; +//! Inflate, Rotate and the classic 3D effects). Gestures edit fields; //! the existing effect preview applies commands and owns OK, Cancel and undo. use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2}; use serde_json::json; -use vectorcraft_effects::REVOLVE; +use vectorcraft_effects::{CLASSIC_REVOLVE, REVOLVE}; use vectorcraft_geom::Point; use vectorcraft_three_d::Revolve; @@ -20,14 +20,11 @@ pub(crate) fn lighting(d: &Dialog) -> bool { d.str("__gizmo") == "light" } -/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic +/// Does the effect take light: Shading on, or for the classic 3D effects a Diffuse or Plastic /// surface? fn lit(d: &Dialog) -> bool { - if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE { - matches!(d.str("surface").as_str(), "diffuse" | "plastic") - } else { - d.bool("shade") - } + let id = d.str("__effect"); + if vectorcraft_effects::is_classic_three_d(&id) { matches!(d.str("surface").as_str(), "diffuse" | "plastic") } else { d.bool("shade") } } pub(crate) fn hint(d: &Dialog) -> &'static str { @@ -97,8 +94,9 @@ impl Geometry { let bounds = node.geometric_bounds()?; let xf = Xf::new(app.canvas_rect?, app.view()?); let options = super::revolve::light_options(d); - // Revolve turns about its axis; the others about the centre of the art's bounds. - let origin = if d.str("__effect") == REVOLVE { options.rotation_origin(bounds) } else { bounds.center() }; + // Both Revolves turn about their axis; the others about the centre of the art's bounds. + let id = d.str("__effect"); + let origin = if id == REVOLVE || id == CLASSIC_REVOLVE { options.rotation_origin(bounds) } else { bounds.center() }; let center = xf.to_screen(origin); if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) { return None; diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 924f8c4078..64dc2fae8c 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -133,8 +133,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } /// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML -/// output, else the image); the web downloads them. The status bar shows the export's first -/// warning. +/// output, else the image); the web downloads them. The status bar shows the export's warnings as +/// [`io::report_saved`] shows a save's notes. pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result { // Bad settings never open a save panel. let s = app.session.web_settings(&p)?; @@ -170,7 +170,7 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result bool { r["warnings"].as_array().is_some_and(|w| !w.is_empty()) } @@ -183,8 +183,8 @@ pub(super) fn preview_name(stem: &str) -> &str { } /// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the -/// default browser (desktop). When the export has warnings, the status bar shows the first in -/// place of "Opened …". +/// default browser (desktop). When the export has warnings, the status bar shows them as +/// [`io::report_saved`] shows a save's notes. pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result { if io::is_web(app) { return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into()); diff --git a/crates/ui-egui/src/dialogs/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/dialogs/tests_hpgl.rs b/crates/ui-egui/src/dialogs/tests_hpgl.rs new file mode 100644 index 0000000000..238a254cfb --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_hpgl.rs @@ -0,0 +1,28 @@ +//! Export As → HP-GL, drawn headlessly: listed with the other formats, written straight away (no +//! options dialog), one plot per artboard with Use Artboards. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, names, set}; +use super::*; + +#[test] +fn export_as_lists_hpgl_and_writes_plots() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "hpgl"); + frame(&mut app); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "no options dialog follows"); + assert_eq!(r["path"], "/out/Untitled-1.plt"); + assert!(written.borrow()[0].1.starts_with(b"IN;")); + assert!(written.borrow()[0].1.ends_with(b"PU;SP0;")); + // One plot per artboard. + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + let n = names(&written); + assert_eq!(&n[1..], ["/out/Untitled-1-Artboard-1.plt", "/out/Untitled-1-Artboard-2.plt"]); +} diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 140623f209..97e2b16ee7 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -508,6 +508,35 @@ fn the_status_bar_says_when_an_image_could_not_be_decoded() { let _ = std::fs::remove_dir_all(dir); } +/// An export into a folder with several notes, from Export for Screens and from the Asset Export +/// panel: the status bar names the first, counts them, and keeps every one of them for its hover. +#[test] +fn an_export_into_a_folder_keeps_every_note_for_the_hover() { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let dir = temp_folder("notes"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}]); + let (mut app, _) = app(1, false); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({"at": [10, 10]})); + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(app.ui.status.starts_with(&format!("Saved {folder} with 2 notes: {}", notes[0])), "{}", app.ui.status); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + app.ui.status_notes = None; + app.run("select.all", json!({})).unwrap(); + app.run("assets.add", json!({"multiple": false})).unwrap(); + let r = crate::io::export_files(&mut app, "assets.export", json!({"folder": folder, "formats": rows})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)), "assets.export"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_preset_export_reopens_on_its_rows() { let (mut app, _) = app(1, true); diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index 06d034fce4..a3857c914a 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,11 +1,12 @@ -//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options -//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on -//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect -//! shares. +//! The own controls of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) in the +//! compact 3D dialog: the extrude and bevel options, or the revolve options, on the Object tab +//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps and shading +//! color on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D +//! effect shares. use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::CLASSIC_EXTRUDE; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE}; use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; use crate::state::Dialog; @@ -25,10 +26,28 @@ pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { }); } +/// Revolve (Classic)'s angle, cap and offset, and the edge of the profile's bounds the axis is at. +pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + heading(ui, tl!("Object")); + number(ui, d, "angle", tl!("Angle"), (0.0, 360.0), "°", 1, None); + choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]); + number(ui, d, "offset", tl!("Offset"), (0.0, 1000.0), "", 1, Some(unit)); + let sides: Vec<(Value, &str)> = super::effect::own_choices(CLASSIC_REVOLVE, "edge", false) + .unwrap_or_default() + .into_iter() + .map(|(label, value)| (json!(value), label)) + .collect(); + choice(ui, d, "edge", tl!("Axis side"), &sides); +} + /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only), Blend Steps and Shading Color; Draw Hidden Faces last. +/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps and Shading Color; +/// Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and +/// Rotate (Classic)'s plane is seen from either side). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { - choice(ui, d, "surface", tl!("Surface"), &options("surface")); + let id = d.str("__effect"); + let effect = if id == CLASSIC_ROTATE { CLASSIC_ROTATE } else { CLASSIC_EXTRUDE }; + choice(ui, d, "surface", tl!("Surface"), &options_of(effect, "surface")); let surface = d.str("surface"); ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| { light_list(ui, d); @@ -42,22 +61,31 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { store_selected(d, &selected); } number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None); - ui.add_enabled_ui(surface == "plastic", |ui| { - number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); - number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); - }); + if id != CLASSIC_ROTATE { + ui.add_enabled_ui(surface == "plastic", |ui| { + number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); + }); + } number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); - choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor")); + choice(ui, d, "shadingColor", tl!("Shading Color"), &options_of(effect, "shadingColor")); if d.str("shadingColor") == "custom" { shading_custom(ui, d); } }); - toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); + if id == CLASSIC_EXTRUDE { + toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); + } } /// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). fn options(key: &str) -> Vec<(Value, &'static str)> { - super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() + options_of(CLASSIC_EXTRUDE, key) +} + +/// The values of classic 3D effect `effect`'s `key` as the dropdowns list them, (value, label). +fn options_of(effect: &str, key: &str) -> Vec<(Value, &'static str)> { + super::effect::own_choices(effect, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() } /// The custom shading color: a color button, its hex value and its reset dot. diff --git a/crates/ui-egui/src/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 c66a8c6339..a5656d5581 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1119,6 +1119,8 @@ font-technology Variable Променливи Extrude & Bevel… Избутване и фасетка… 3D (Classic) 3D (класически) Extrude & Bevel (Classic)… Избутване и фасетка (класически)… + Revolve (Classic)… Завъртане (класическо)… + Rotate (Classic)… Ротация (класическа)… Surface Повърхност Wireframe Телена рамка No Shading Без засенчване @@ -1399,6 +1401,14 @@ font-technology Variable Променливи Hair Space Тънък интервал Hand Tool Инструмент „Ръка“ Hand-Drawn Ръчно рисувани + Painted and Sketched Рисувани и скицирани + Washed Blue Размито синьо + Bristle Ochre Охра с четка + Pastel Heather Пастелен пирен + Charcoal Shade Сянка с въглен + Ink Contour Мастилен контур + Wet Ink Мокро мастило + Fine Pen Фино перо Handles Дръжки Hard Light Твърда светлина Hard Mix Твърдо смесване @@ -2282,6 +2292,8 @@ font-technology Variable Променливи Options for Selection… Опции за селектиране… Options: Опции: Options… Опции… + Organic Fades Органични преливания + Holographic Холографски or more spaces with a tab или повече интервала с табулация Order: Ред: Ordinals Поредни номера @@ -2473,6 +2485,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 +2578,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растерен ефект Raster File Format: Растерен файлов формат: + Include Placed Images Включване на поставените изображения + Include Fill Hatches Включване на щриховките на запълванията Raster Sample Size: Растерен размер на пробата: Raster/Vector Balance: Баланс растер/вектор: Rasterize Растеризиране @@ -2745,6 +2760,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 Замяна с шрифт @@ -2823,6 +2842,9 @@ digits Malayalam Малаялам Rise… Издигане… Roman Baseline Латинска основна линия Roman Hanging Punctuation Латинска висяща пунктуация + Korean Line Break Anywhere Пренасяне на корейски текст навсякъде + East Asian Composition Settings Настройки за източноазиатска композиция + Korean Composition Settings Настройки за корейска композиция Rotate Завъртане Rotate 90° CW Завъртане на 90° по часовниковата стрелка Rotate Tool Инструмент „Завъртане“ @@ -4391,6 +4413,7 @@ axis Both И двете @msg not a valid plug-in module: {0} не е валиден модул за плъгин: {0} @msg not an EMF or WMF file не е EMF или WMF файл @msg not an HP-GL file не е HP-GL файл +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` трябва да бъде масив от 1 до 255 цвята във формат #rrggbb @msg not found не е открито @msg not in pattern editing mode не е в режим на редактиране на мостра @msg finish editing the pattern first първо завършете редактирането на мострата @@ -5836,6 +5859,7 @@ theme Appearance Външен вид Ruby Руби Geometric Guides Геометрични водачи Geometric Patterns Геометрични мостри + Grit Textures Зърнести текстури Ease In Плавно начало Ease Out Плавен край Ease In Out Плавно начало и край @@ -5889,6 +5913,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 Промяна, добавяне и изтриване на обекти в документа @@ -6034,6 +6059,17 @@ axis Y Axis Y ос Charge Wave Вълна на заряд Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци + Bristle Brushes Четки с косъм + Fine Liner Тънък контур + Pointed Flat Заострена плоска + Stubby Round Къса кръгла + Dry Scrub Сухо търкане + Soft Dome Мек купол + Filbert Плоска овална + Angled Round Скосена кръгла + Angled Flat Скосена плоска + Fan Blender Ветрило за смесване + Grass Fan Ветрило за трева Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури Save Brush Library… Запази библиотека с четки… @@ -6378,6 +6414,12 @@ breakText Glyphs Глифи Delete the selected brushes? Да се изтрият избраните четки? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките? Remove Strokes Премахване на щрихи + Place Brush Art Поставяне на графика на четка + Replace Brush Art Замяна на графика на четка + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Щрихи използват тази четка. Да се приложи ли новата графика към тези щрихи, или те да останат както са и да се добави копие на четката с новата графика? + Apply to Strokes Прилагане към щрихите + Leave Strokes Оставяне на щрихите +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` няма графика (имат я само разпръснатите и артистичните четки и четките с мостра) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменти за подреждане Arrange in Grid… Подреждане в мрежа… @@ -6560,6 +6602,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 Безопасен режим @@ -6607,6 +6654,19 @@ breakText Glyphs Глифи @plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати @plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени +# Learn panel + Learn Обучение + Explore tutorials Разгледайте уроците + For you За вас + Skills Умения + Get started Първи стъпки + Quick skill guide Бързо ръководство за умения + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Кратки практически уроци за основните инструменти, групирани по умения. Изберете умение, за да видите уроците му. + List Tutorials Извеждане на уроци + Show Tutorial Показване на урок +@msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) +@msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) + # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти Deferred Effects Отложени ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a92d6cb1fa..03c55c5dcf 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 @@ -483,6 +487,8 @@ Extrude & Bevel… Istiskivanje i zakošenje… 3D (Classic) 3D (klasično) Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… + Revolve (Classic)… Rotaciono tijelo (klasično)… + Rotate (Classic)… Rotiraj (klasično)… Surface Površina Wireframe Žičani model No Shading Bez sjenčenja @@ -556,6 +562,7 @@ Gaussian Blur… Gaussovo zamućenje… View Prikaz Preview Pregled + Preview: Pregled: Overprint Preview Pregled pretiska Pixel Preview Pregled piksela Trim View Prikaz obrezivanja @@ -807,6 +814,14 @@ Damp Paper Vlažni papir Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano + Painted and Sketched Slikano i skicirano + Washed Blue Isprano plavo + Bristle Ochre Oker kistom + Pastel Heather Pastelni vrijesak + Charcoal Shade Sjena ugljenom + Ink Contour Kontura tušem + Wet Ink Mokri tuš + Fine Pen Fino pero Gradient Finishes Gradijentne završnice Shape Effects Efekti oblika Blends and Transparency Stapanja i prozirnost @@ -826,6 +841,8 @@ Spectrum Gradients Gradijenti spektra Sky Gradients Gradijenti neba Neutral Gradients Neutralni gradijenti + Organic Fades Organski prelazi + Holographic Holografski Duotone Gradients Duoton gradijenti Radial Glows Radijalni sjajevi Save Swatch Library… Sačuvaj biblioteku uzoraka boja… @@ -1973,6 +1990,9 @@ digits Malayalam Malajalam Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo Roman Hanging Punctuation Latinična viseća interpunkcija + Korean Line Break Anywhere Prelom korejskog reda bilo gdje + East Asian Composition Settings Postavke istočnoazijske kompozicije + Korean Composition Settings Postavke korejske kompozicije Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa. Single-line Composer Slagač jednog reda Toggle Line Composer Uključi/isključi Line Composer @@ -2360,6 +2380,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. @@ -4782,6 +4804,7 @@ imageTrace Outline Obris @msg not a valid plug-in module: {0} nije važeći modul dodatka: {0} @msg not an EMF or WMF file nije EMF ni WMF datoteka @msg not an HP-GL file nije HP-GL datoteka +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` mora biti niz od 1 do 255 boja #rrggbb @msg not an export presets file (format `{_1}`) nije datoteka predložaka izvoza (format `{_1}`) @msg not an export presets file: {e} nije datoteka predložaka izvoza: {e} @msg not found nije pronađeno @@ -5853,6 +5876,7 @@ mark-style Japanese Japanski Ruby Ruby Geometric Guides Geometrijske vodilice Geometric Patterns Geometrijski uzorci + Grit Textures Zrnaste teksture Ease In Ublaži ulaz Ease Out Ublaži izlaz Ease In Out Ublaži ulaz i izlaz @@ -5910,6 +5934,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 @@ -6084,6 +6110,17 @@ axis Y Axis Y osa Charge Wave Talas naboja Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi + Bristle Brushes Četkice s čekinjama + Fine Liner Tanka linija + Pointed Flat Šiljata ravna + Stubby Round Kratka okrugla + Dry Scrub Suho trljanje + Soft Dome Meka kupola + Filbert Ovalna ravna + Angled Round Kosa okrugla + Angled Flat Kosa ravna + Fan Blender Lepeza za stapanje + Grass Fan Lepeza za travu Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici Save Brush Library… Sačuvaj biblioteku četkica… @@ -6428,6 +6465,12 @@ shape Open Otvoren Delete the selected brushes? Izbrisati odabrane četkice? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice? Remove Strokes Ukloni poteze + Place Brush Art Postavi crtež četkice + Replace Brush Art Zamijeni crtež četkice + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Potezi koriste ovu četkicu. Primijeniti novi crtež na te poteze ili ih ostaviti kakvi jesu i dodati kopiju četkice s novim crtežom? + Apply to Strokes Primijeni na poteze + Leave Strokes Ostavi poteze +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nema crtež (imaju ga samo raspršene i umjetničke četkice i četkice uzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alati za raspoređivanje Arrange in Grid… Rasporedi u mrežu… @@ -6611,6 +6654,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 @@ -6658,6 +6706,19 @@ shape Open Otvoren @plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno @plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno +# Learn panel + Learn Učenje + Explore tutorials Istražite tutorijale + For you Za vas + Skills Vještine + Get started Prvi koraci + Quick skill guide Brzi vodič kroz vještine + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kratki praktični tutorijali za osnovne alate, grupisani po vještinama. Odaberite vještinu da vidite njene tutorijale. + List Tutorials Izlistaj tutorijale + Show Tutorial Prikaži tutorijal +@msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) +@msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) + # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte Deferred Effects Odgođeni efekti diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b616bbc426..d1416f5834 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -608,6 +608,17 @@ antiAlias Sharp Nítid Calligraphic Brush Pinzell cal·ligràfic Calligraphic Brush Options Opcions del pinzell cal·ligràfic Calligraphic Nibs Plomes cal·ligràfiques + Bristle Brushes Pinzells de cerres + Fine Liner Traç fi + Pointed Flat Pla punxegut + Stubby Round Rodó curt + Dry Scrub Fregat sec + Soft Dome Cúpula suau + Filbert Llengua de gat + Angled Round Rodó bisellat + Angled Flat Pla bisellat + Fan Blender Ventall difuminador + Grass Fan Ventall d'herba Can't export “{name}”. No es pot exportar “{name}”. Can't open “{name}”. No es pot obrir “{name}”. Can't print these settings: see the Summary No es poden imprimir aquests paràmetres: consulta el resum @@ -1573,6 +1584,8 @@ antiAlias Sharp Nítid Extrude & Bevel… Extrudeix i bisella… 3D (Classic) 3D (clàssic) Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… + Revolve (Classic)… Revoluciona (clàssic)… + Rotate (Classic)… Gira (clàssic)… Surface Superfície Wireframe Filferro No Shading Sense ombra @@ -1882,6 +1895,7 @@ digits Malayalam Malaiàlam Gridline every Línia de quadrícula cada Gridline every: Línia de quadrícula cada: Grids In Back Quadrícules al darrere + Grit Textures Textures granulades Group Agrupa Group Selection Selecció de grup Group Selection Tool Eina de selecció de grup @@ -1906,6 +1920,14 @@ digits Malayalam Malaiàlam Hand Mà Hand Tool Eina de mà Hand-Drawn Dibuixat a mà + Painted and Sketched Pintat i esbossat + Washed Blue Blau rentat + Bristle Ochre Ocre de cerres + Pastel Heather Bruc pastel + Charcoal Shade Ombra de carbó + Ink Contour Contorn de tinta + Wet Ink Tinta humida + Fine Pen Ploma fina Handles Nanses Hard Dur Hard Light Llum dura @@ -2917,6 +2939,8 @@ digits Malayalam Malaiàlam Options for Selection… Opcions per a la selecció… Options: Opcions: Options… Opcions… + Organic Fades Esvaïments orgànics + Holographic Hologràfics or more spaces with a tab o més espais amb un tabulador Orange Taronja Order: Ordre: @@ -3207,6 +3231,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 +3333,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 +3521,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 @@ -3613,6 +3644,9 @@ digits Malayalam Malaiàlam Roman Romà Roman Baseline Línia de base romana Roman Hanging Punctuation Puntuació penjant romana + Korean Line Break Anywhere Salt de línia coreà a qualsevol lloc + East Asian Composition Settings Configuració de composició de l’Àsia oriental + Korean Composition Settings Configuració de composició coreana Rotate Gira Rotate 90° CW Gira 90° en sentit horari Rotate Tool Eina de gir @@ -5073,6 +5107,7 @@ digits Malayalam Malaiàlam @msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5 @msg `select` must be true or false `select` ha de ser true o false @msg `stops` must be an array `stops` ha de ser una matriu +@msg `{key}` must be an array `{key}` ha de ser una matriu @msg `svg` must be an object of SVG options (see document.formats) `svg` ha de ser un objecte d’opcions SVG (vegeu document.formats) @msg `to` must be "corner" or "smooth" `to` ha de ser "corner" o "smooth" @msg `{_1}` has no brushes to apply `{_1}` no té pinzells per aplicar @@ -5633,6 +5668,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF @msg not an HP-GL file no és un fitxer HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius @@ -6122,6 +6158,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 +6222,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 @@ -6466,6 +6510,10 @@ anchor Points: Punts: @msg features lists at most {max} tags features admet com a màxim {max} etiquetes @msg unknown OpenType feature `{t}` funció OpenType desconeguda `{t}` @msg {label} must be a number above 0 and at most {max} {label} ha de ser un nombre superior a 0 i com a màxim {max} +@msg {label} must be at most {max} characters {label} ha de tenir com a màxim {max} caràcters +@msg {label} must be one character {label} ha de ser un sol caràcter +@msg at most {max} tab stops com a màxim {max} tabulacions +@msg unknown align `{other}` alineació desconeguda `{other}` @msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6636,6 +6684,12 @@ breakText Glyphs Glifs Delete the selected brushes? Voleu suprimir els pinzells seleccionats? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells? Remove Strokes Elimina els traços + Place Brush Art Col·loca l’art del pinzell + Replace Brush Art Substitueix l’art del pinzell + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Hi ha traços que fan servir aquest pinzell. Voleu aplicar l’art nou a aquests traços, o deixar-los com estan i afegir una còpia del pinzell amb l’art nou? + Apply to Strokes Aplica als traços + Leave Strokes Deixa els traços +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no té art (només en tenen els pinzells de dispersió, artístics i de motiu) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Eines d’organització Arrange in Grid… Organitza en quadrícula… @@ -6831,6 +6885,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 @@ -6883,6 +6942,19 @@ breakText Glyphs Glifs @plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last` +# Learn panel + Learn Aprèn + Explore tutorials Explora els tutorials + For you Per a vós + Skills Habilitats + Get started Primers passos + Quick skill guide Guia ràpida d’habilitats + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorials pràctics breus de les eines essencials, agrupats per habilitat. Trieu una habilitat per veure’n els tutorials. + List Tutorials Llista els tutorials + Show Tutorial Mostra el tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats Deferred Effects Efectes ajornats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9fdfd23102..4a2d66ffed 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 @@ -492,6 +496,8 @@ Extrude & Bevel… Vysunutí a zkosení… 3D (Classic) 3D (klasické) Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… + Revolve (Classic)… Rotační těleso (klasické)… + Rotate (Classic)… Otočit (klasické)… Surface Povrch Wireframe Drátový model No Shading Bez stínování @@ -565,6 +571,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í @@ -816,6 +823,14 @@ Damp Paper Vlhký papír Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené + Painted and Sketched Malované a skicované + Washed Blue Rozmytá modrá + Bristle Ochre Okr štětcem + Pastel Heather Pastelový vřes + Charcoal Shade Stín uhlem + Ink Contour Obrys tuší + Wet Ink Mokrá tuš + Fine Pen Jemné pero Gradient Finishes Přechodové povrchy Shape Effects Efekty tvarů Blends and Transparency Prolnutí a průhlednost @@ -835,6 +850,8 @@ Spectrum Gradients Spektrální přechody Sky Gradients Přechody oblohy Neutral Gradients Neutrální přechody + Organic Fades Organické přechody + Holographic Holografické Duotone Gradients Dvoutónové přechody Radial Glows Kruhové záře Save Swatch Library… Uložit knihovnu vzorníku… @@ -2000,6 +2017,9 @@ digits Malayalam Malajálamské Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva Roman Hanging Punctuation Visící latinková interpunkce + Korean Line Break Anywhere Zalomení korejského řádku kdekoli + East Asian Composition Settings Nastavení východoasijské sazby + Korean Composition Settings Nastavení korejské sazby Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce. Single-line Composer Sazba jednoho řádku Toggle Line Composer Přepnout sazbu řádků @@ -2389,6 +2409,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. @@ -4552,6 +4574,7 @@ imageTrace Outline Obrys @msg `globalAdjust` must be an object `globalAdjust` musí být objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5 @msg `stops` must be an array `stops` musí být pole +@msg `{key}` must be an array `{key}` musí být pole @msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats) @msg `{_1}` is no save command `{_1}` není příkaz pro uložení @msg `{area}` is no recovery area name `{area}` není název oblasti obnovení @@ -4867,6 +4890,7 @@ imageTrace Outline Obrys @msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0} @msg not an EMF or WMF file nejde o soubor EMF ani WMF @msg not an HP-GL file nejde o soubor HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku @msg finish editing the pattern first nejprve dokončete úpravy vzorku @@ -6017,6 +6041,7 @@ mark-style Japanese Japonské Ruby Ruby Geometric Guides Geometrická vodítka Geometric Patterns Geometrické vzorky + Grit Textures Zrnité textury Ease In Pozvolný začátek Ease Out Pozvolný konec Ease In Out Pozvolný začátek a konec @@ -6075,6 +6100,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 +6164,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 @@ -6285,6 +6318,17 @@ axis Y Axis osa Y Charge Wave Nabíjecí vlna Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera + Bristle Brushes Štětinové štětce + Fine Liner Jemná linka + Pointed Flat Špičatý plochý + Stubby Round Krátký kulatý + Dry Scrub Suché drhnutí + Soft Dome Měkká kopule + Filbert Plochý oválný + Angled Round Šikmý kulatý + Angled Flat Šikmý plochý + Fan Blender Vějíř na prolínání + Grass Fan Vějíř na trávu Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary Save Brush Library… Uložit knihovnu štětců… @@ -6494,6 +6538,10 @@ anchor Points: Body: @msg features lists at most {max} tags features obsahuje nejvýše {max} značek @msg unknown OpenType feature `{t}` neznámá funkce OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musí být číslo větší než 0 a nejvýše {max} +@msg {label} must be at most {max} characters {label} smí mít nejvýše {max} znaků +@msg {label} must be one character {label} musí být jeden znak +@msg at most {max} tab stops nejvýše {max} zarážek tabulátoru +@msg unknown align `{other}` neznámé zarovnání `{other}` @msg no character style `{name}` styl znaků `{name}` neexistuje # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6655,6 +6703,12 @@ breakText Glyphs Glyfy Delete the selected brushes? Odstranit vybrané štětce? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce? Remove Strokes Odebrat tahy + Place Brush Art Umístit kresbu štětce + Replace Brush Art Nahradit kresbu štětce + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Tahy používají tento štětec. Použít novou kresbu na tyto tahy, nebo je ponechat beze změny a přidat kopii štětce s novou kresbou? + Apply to Strokes Použít na tahy + Leave Strokes Ponechat tahy +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nemá kresbu (mají ji jen rozptylové, obrazové a vzorkové štětce) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Nástroje uspořádání Arrange in Grid… Uspořádat do mřížky… @@ -6850,6 +6904,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 @@ -6902,6 +6961,19 @@ breakText Glyphs Glyfy @plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno @msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` +# Learn panel + Learn Výuka + Explore tutorials Prozkoumat výukové kurzy + For you Pro vás + Skills Dovednosti + Get started Začínáme + Quick skill guide Rychlý průvodce dovednostmi + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krátké praktické výukové kurzy k základním nástrojům, seřazené podle dovedností. Vyberte dovednost a zobrazí se její kurzy. + List Tutorials Vypsat výukové kurzy + Show Tutorial Zobrazit výukový kurz +@msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) +@msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) + # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty Deferred Effects Odložené efekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1c9744635c..4f0c96803f 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 @@ -450,6 +454,8 @@ Extrude & Bevel… Ekstrudér og facet… 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… + Revolve (Classic)… Omdrej (klassisk)… + Rotate (Classic)… Rotér (klassisk)… Surface Overflade Wireframe Trådmodel No Shading Ingen skyggelægning @@ -520,6 +526,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 @@ -753,6 +760,14 @@ Damp Paper Fugtigt papir Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet + Painted and Sketched Malet og skitseret + Washed Blue Udvasket blå + Bristle Ochre Børsteokker + Pastel Heather Pastellyng + Charcoal Shade Kulskygge + Ink Contour Blækkontur + Wet Ink Vådt blæk + Fine Pen Fin pen Gradient Finishes Gradientfinish Shape Effects Formeffekter Blends and Transparency Blandinger og gennemsigtighed @@ -772,6 +787,8 @@ Spectrum Gradients Spektrumgradienter Sky Gradients Himmelgradienter Neutral Gradients Neutrale gradienter + Organic Fades Organiske toninger + Holographic Holografiske Duotone Gradients Duotonegradienter Radial Glows Radiale gløder Save Swatch Library… Gem bibliotek med farveprøver… @@ -1806,6 +1823,9 @@ digits Malayalam Malayalam Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre Roman Hanging Punctuation Romersk hængende tegnsætning + Korean Line Break Anywhere Koreansk linjeskift hvor som helst + East Asian Composition Settings Indstillinger for østasiatisk komposition + Korean Composition Settings Indstillinger for koreansk komposition Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. Single-line Composer Enlinjesætter Toggle Line Composer Skift linjesætter @@ -2192,6 +2212,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. @@ -4492,6 +4514,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0} @msg not an EMF or WMF file ikke en EMF- eller WMF-fil @msg not an HP-GL file ikke en HP-GL-fil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` skal være et array af 1 til 255 #rrggbb-farver @msg not found ikke fundet @msg not in pattern editing mode ikke i mønsterredigeringstilstand @msg finish editing the pattern first afslut først redigeringen af mønsteret @@ -5851,6 +5874,7 @@ swatch Bright Violet Kraftig violet Ruby Ruby Geometric Guides Geometriske hjælpelinjer Geometric Patterns Geometriske mønstre + Grit Textures Kornede teksturer Ease In Langsom start Ease Out Langsom slutning Ease In Out Langsom start og slutning @@ -5904,6 +5928,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 @@ -6049,6 +6074,17 @@ axis Y Axis Y-akse Charge Wave Opladningsbølge Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne + Bristle Brushes Børstepensler + Fine Liner Fin streg + Pointed Flat Spids flad + Stubby Round Kort rund + Dry Scrub Tør skrubning + Soft Dome Blød kuppel + Filbert Oval flad + Angled Round Skrå rund + Angled Flat Skrå flad + Fan Blender Blendevifte + Grass Fan Græsvifte Pattern Borders Mønsterborter Scatter Shapes Spredte former Save Brush Library… Gem penselbibliotek… @@ -6392,6 +6428,12 @@ breakText Glyphs Glyffer Delete the selected brushes? Slet de valgte pensler? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne? Remove Strokes Fjern streger + Place Brush Art Placér penselgrafik + Replace Brush Art Erstat penselgrafik + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Streger bruger denne pensel. Anvend den nye grafik på stregerne, eller lad dem være, som de er, og tilføj en kopi af penslen med den nye grafik? + Apply to Strokes Anvend på streger + Leave Strokes Bevar streger +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (det har kun sprednings-, grafik- og mønsterpensler) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Arrangeringsværktøjer Arrange in Grid… Arrangér i gitter… @@ -6574,6 +6616,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 @@ -6621,6 +6668,19 @@ breakText Glyphs Glyffer @plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet @plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet +# Learn panel + Learn Lær + Explore tutorials Udforsk vejledninger + For you Til dig + Skills Færdigheder + Get started Kom i gang + Quick skill guide Hurtig færdighedsguide + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktiske vejledninger til de vigtigste værktøjer, ordnet efter færdighed. Vælg en færdighed for at se dens vejledninger. + List Tutorials Vis vejledninger + Show Tutorial Vis vejledning +@msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) +@msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) + # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter Deferred Effects Udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2ec3b656c9..141128626d 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 @@ -1124,6 +1132,8 @@ font-technology Variable Variabel Extrude & Bevel… Extrudieren und abgeflachte Kante… 3D (Classic) 3D (klassisch) Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… + Revolve (Classic)… Kreiseln (klassisch)… + Rotate (Classic)… Drehen (klassisch)… Surface Oberfläche Wireframe Drahtmodell No Shading Keine Schattierung @@ -1436,6 +1446,14 @@ file-filter Templates Vorlagen Hair Space Haarspatium Hand Tool Hand-Werkzeug Hand-Drawn Handgezeichnet + Painted and Sketched Gemalt und skizziert + Washed Blue Verwaschenes Blau + Bristle Ochre Borstenocker + Pastel Heather Pastellheide + Charcoal Shade Kohleschatten + Ink Contour Tuschekontur + Wet Ink Nasse Tusche + Fine Pen Feine Feder Handles Griffe Hard Light Hartes Licht Hard Mix Hart mischen @@ -2310,6 +2328,8 @@ file-filter Templates Vorlagen Options for Selection… Optionen für Auswahl… Options: Optionen: Options… Optionen… + Organic Fades Organische Verläufe + Holographic Holografisch or more spaces with a tab oder mehr Leerzeichen durch einen Tabulator Order: Reihenfolge: Ordinals Ordinalzahlen @@ -2503,6 +2523,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 +2618,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 +2801,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 @@ -2857,6 +2884,9 @@ digits Malayalam Malayalam Rise… Ansteigend… Roman Baseline Lateinische Grundlinie Roman Hanging Punctuation Hängende Interpunktion (lateinisch) + Korean Line Break Anywhere Koreanischer Zeilenumbruch überall + East Asian Composition Settings Einstellungen für ostasiatischen Satz + Korean Composition Settings Einstellungen für koreanischen Satz Rotate Drehen Rotate 90° CW Um 90° im Uhrzeigersinn drehen Rotate Tool Drehen-Werkzeug @@ -4159,6 +4189,7 @@ theme Appearance Erscheinungsbild @msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein @msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein @msg `stops` must be an array `stops` muss ein Array sein +@msg `{key}` must be an array `{key}` muss ein Array sein @msg `svg` must be an object of SVG options (see document.formats) `svg` muss ein Objekt mit SVG-Optionen sein (siehe document.formats) @msg `{_1}` is no save command `{_1}` ist kein Speicherbefehl @msg `{area}` is no recovery area name `{area}` ist kein Name eines Wiederherstellungsbereichs @@ -4476,6 +4507,7 @@ theme Appearance Erscheinungsbild @msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0} @msg not an EMF or WMF file keine EMF- oder WMF-Datei @msg not an HP-GL file keine HP-GL-Datei +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus @msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters @@ -5598,6 +5630,7 @@ effect Soft Weich Ruby Ruby Geometric Guides Geometrische Hilfslinien Geometric Patterns Geometrische Muster + Grit Textures Körnige Texturen Ease In Langsam starten Ease Out Langsam enden Ease In Out Langsam starten und enden @@ -5658,6 +5691,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 +5755,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 @@ -5868,6 +5909,17 @@ axis Y Axis Y-Achse Charge Wave Ladewelle Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn + Bristle Brushes Borstenpinsel-Set + Fine Liner Feiner Liner + Pointed Flat Spitzer Flachpinsel + Stubby Round Kurzer Rundpinsel + Dry Scrub Trockenes Schrubben + Soft Dome Weiche Kuppel + Filbert Katzenzunge + Angled Round Schräger Rundpinsel + Angled Flat Schräger Flachpinsel + Fan Blender Fächer zum Verblenden + Grass Fan Grasfächer Pattern Borders Musterbordüren Scatter Shapes Streuformen Save Brush Library… Pinsel-Bibliothek speichern… @@ -6080,6 +6132,10 @@ anchor Points: Punkte: @msg features lists at most {max} tags features enthält höchstens {max} Tags @msg unknown OpenType feature `{t}` unbekannte OpenType-Funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} muss eine Zahl über 0 und höchstens {max} sein +@msg {label} must be at most {max} characters {label} darf höchstens {max} Zeichen lang sein +@msg {label} must be one character {label} muss genau ein Zeichen sein +@msg at most {max} tab stops höchstens {max} Tabstopps +@msg unknown align `{other}` unbekannte Ausrichtung `{other}` @msg no character style `{name}` Zeichenformat `{name}` nicht gefunden # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6242,6 +6298,12 @@ breakText Glyphs Glyphen Delete the selected brushes? Ausgewählte Pinsel löschen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen? Remove Strokes Konturen entfernen + Place Brush Art Pinselgrafik platzieren + Replace Brush Art Pinselgrafik ersetzen + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturen verwenden diesen Pinsel. Die neue Grafik auf diese Konturen anwenden oder sie unverändert lassen und eine Kopie des Pinsels mit der neuen Grafik hinzufügen? + Apply to Strokes Auf Konturen anwenden + Leave Strokes Konturen beibehalten +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` hat keine Grafik (nur Spezial-, Bild- und Musterpinsel haben eine) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Anordnungswerkzeuge Arrange in Grid… In Raster anordnen… @@ -6437,6 +6499,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 @@ -6489,6 +6556,19 @@ breakText Glyphs Glyphen @plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt @msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last` +# Learn panel + Learn Lernen + Explore tutorials Lernprogramme entdecken + For you Für Sie + Skills Fertigkeiten + Get started Erste Schritte + Quick skill guide Kurzanleitung zu Fertigkeiten + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kurze praktische Lernprogramme zu den wichtigsten Werkzeugen, nach Fertigkeiten geordnet. Wählen Sie eine Fertigkeit, um ihre Lernprogramme zu sehen. + List Tutorials Lernprogramme auflisten + Show Tutorial Lernprogramm anzeigen +@msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) +@msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) + # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren Deferred Effects Zurückgestellte Effekte diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 64b1ad12e0..99c6c3e215 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1097,6 +1097,8 @@ font-technology Variable Μεταβλητές Extrude & Bevel… Εξώθηση και λοξότμηση… 3D (Classic) 3D (κλασικό) Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… + Revolve (Classic)… Περιστροφή γύρω από άξονα (κλασικό)… + Rotate (Classic)… Περιστροφή (κλασικό)… Surface Επιφάνεια Wireframe Συρματόπλεγμα No Shading Χωρίς σκίαση @@ -1374,6 +1376,14 @@ font-technology Variable Μεταβλητές Hair Space Πολύ λεπτό διάστημα Hand Tool Χέρι (Hand Tool) Hand-Drawn Σχεδιασμένο στο χέρι + Painted and Sketched Ζωγραφισμένο και σκιτσαρισμένο + Washed Blue Ξεπλυμένο μπλε + Bristle Ochre Ώχρα με πινέλο + Pastel Heather Παστέλ ρείκι + Charcoal Shade Σκιά με κάρβουνο + Ink Contour Περίγραμμα με μελάνι + Wet Ink Υγρό μελάνι + Fine Pen Λεπτή πένα Handles Λαβές Hard Light Έντονο φως Hard Mix Έντονη ανάμειξη @@ -1841,6 +1851,8 @@ font-technology Variable Μεταβλητές Options: Επιλογές: Options… Επιλογές… Ordinals Τακτικοί αριθμοί + Organic Fades Οργανικές διαβαθμίσεις + Holographic Ολογραφικά Orientation Προσανατολισμός Orientation: Προσανατολισμός: Original Πρωτότυπο @@ -1889,6 +1901,7 @@ font-technology Variable Μεταβλητές Preset: Προεπιλογή: Presets: Προεπιλογές: Preview Προεπισκόπηση + Preview: Προεπισκόπηση: Previous Προηγούμενο Print Εκτύπωση Printer Εκτυπωτής @@ -1951,6 +1964,9 @@ font-technology Variable Μεταβλητές Right: Δεξιά: Rise… Ανύψωση… Roman Hanging Punctuation Κρεμαστή στίξη όρθιας γραφής + Korean Line Break Anywhere Αλλαγή γραμμής κορεατικών οπουδήποτε + East Asian Composition Settings Ρυθμίσεις σύνθεσης ανατολικής Ασίας + Korean Composition Settings Ρυθμίσεις κορεατικής σύνθεσης Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής @@ -2372,6 +2388,7 @@ axis Both Και τα δύο @msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο @msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5 @msg `stops` must be an array Το `stops` πρέπει να είναι πίνακας +@msg `{key}` must be an array Το `{key}` πρέπει να είναι πίνακας @msg `svg` must be an object of SVG options (see document.formats) Το `svg` πρέπει να είναι αντικείμενο επιλογών SVG (δείτε document.formats) @msg `{_1}` is no save command Το `{_1}` δεν είναι εντολή αποθήκευσης @msg `{area}` is no recovery area name Το `{area}` δεν είναι όνομα περιοχής ανάκτησης @@ -3285,6 +3302,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 +3451,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 Αντικατάσταση με γραμματοσειρά @@ -4514,6 +4537,7 @@ digits Malayalam Μαλαγιάλαμ @msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0} @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF @msg not an HP-GL file δεν είναι αρχείο HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου @msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου @@ -5968,6 +5992,7 @@ effect Points Σημεία Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί Geometric Patterns Γεωμετρικά μοτίβα + Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή Ease Out Ομαλό τέλος Ease In Out Ομαλή αρχή και τέλος @@ -6026,6 +6051,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 +6115,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`: δεν εγκαταστάθηκε τίποτα @@ -6236,6 +6269,17 @@ axis Y Axis Άξονας Y Charge Wave Κύμα φόρτισης Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες + Bristle Brushes Πινέλα με τρίχες + Fine Liner Λεπτή γραμμή + Pointed Flat Μυτερό πλακέ + Stubby Round Κοντό στρογγυλό + Dry Scrub Ξηρό τρίψιμο + Soft Dome Μαλακός θόλος + Filbert Γλώσσα γάτας + Angled Round Λοξό στρογγυλό + Angled Flat Λοξό πλακέ + Fan Blender Βεντάλια ανάμειξης + Grass Fan Βεντάλια για γρασίδι Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @@ -6445,6 +6489,10 @@ anchor Points: Σημεία: @msg features lists at most {max} tags Το features περιέχει έως {max} ετικέτες @msg unknown OpenType feature `{t}` άγνωστο χαρακτηριστικό OpenType `{t}` @msg {label} must be a number above 0 and at most {max} Το {label} πρέπει να είναι αριθμός μεγαλύτερος από 0 και έως {max} +@msg {label} must be at most {max} characters Το {label} πρέπει να έχει έως {max} χαρακτήρες +@msg {label} must be one character Το {label} πρέπει να είναι ένας χαρακτήρας +@msg at most {max} tab stops έως {max} στάσεις στηλοθέτη +@msg unknown align `{other}` άγνωστη στοίχιση `{other}` @msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6607,6 +6655,12 @@ breakText Glyphs Γλύφοι Delete the selected brushes? Να διαγραφούν τα επιλεγμένα πινέλα; Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα; Remove Strokes Αφαίρεση περιγραμμάτων + Place Brush Art Τοποθέτηση σχεδίου πινέλου + Replace Brush Art Αντικατάσταση σχεδίου πινέλου + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Περιγράμματα χρησιμοποιούν αυτό το πινέλο. Να εφαρμοστεί το νέο σχέδιο σε αυτά τα περιγράμματα ή να μείνουν ως έχουν και να προστεθεί αντίγραφο του πινέλου με το νέο σχέδιο; + Apply to Strokes Εφαρμογή στα περιγράμματα + Leave Strokes Διατήρηση περιγραμμάτων +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` δεν έχει σχέδιο (έχουν μόνο τα πινέλα διασποράς, σχεδίου και μοτίβου) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Εργαλεία διάταξης Arrange in Grid… Διάταξη σε πλέγμα… @@ -6802,6 +6856,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 Ασφαλής λειτουργία @@ -6854,6 +6913,19 @@ breakText Glyphs Γλύφοι @plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν @msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last` +# Learn panel + Learn Εκμάθηση + Explore tutorials Εξερεύνηση οδηγών εκμάθησης + For you Για εσάς + Skills Δεξιότητες + Get started Πρώτα βήματα + Quick skill guide Γρήγορος οδηγός δεξιοτήτων + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Σύντομοι πρακτικοί οδηγοί για τα βασικά εργαλεία, ομαδοποιημένοι ανά δεξιότητα. Επιλέξτε μια δεξιότητα για να δείτε τους οδηγούς της. + List Tutorials Λίστα οδηγών εκμάθησης + Show Tutorial Εμφάνιση οδηγού εκμάθησης +@msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) +@msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) + # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή Deferred Effects Εφέ σε αναμονή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d4215a95c2..82ad7299f4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1153,6 +1153,8 @@ font-technology Variable Variables Extrude & Bevel… Extrusión y biselado… 3D (Classic) 3D (clásico) Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… + Revolve (Classic)… Revolución (clásico)… + Rotate (Classic)… Rotar (clásico)… Surface Superficie Wireframe Malla No Shading Sin sombreado @@ -1453,6 +1455,14 @@ font-technology Variable Variables Hair Space Espacio fino Hand Tool Herramienta Mano Hand-Drawn Dibujado a mano + Painted and Sketched Pintado y bocetado + Washed Blue Azul lavado + Bristle Ochre Ocre de cerdas + Pastel Heather Brezo pastel + Charcoal Shade Sombra al carboncillo + Ink Contour Contorno de tinta + Wet Ink Tinta húmeda + Fine Pen Plumilla fina Handles Manejadores Hard Light Luz fuerte Hard Mix Mezcla definida @@ -2356,6 +2366,8 @@ font-technology Variable Variables Options for Selection… Opciones de la selección… Options: Opciones: Options… Opciones… + Organic Fades Desvanecidos orgánicos + Holographic Holográficos or more spaces with a tab o más espacios por una tabulación Order: Orden: Ordinals Ordinales @@ -2547,6 +2559,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 +2652,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 +2835,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 @@ -2898,6 +2917,9 @@ digits Malayalam Malayálam Rise… Ascenso… Roman Baseline Línea de base latina Roman Hanging Punctuation Puntuación colgante romana + Korean Line Break Anywhere Salto de línea coreano en cualquier lugar + East Asian Composition Settings Ajustes de composición de Asia oriental + Korean Composition Settings Ajustes de composición coreana Rotate Rotar Rotate 90° CW Rotar 90° en sentido horario Rotate Tool Herramienta Rotar @@ -4243,6 +4265,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5 @msg `stops` must be an array `stops` debe ser una lista +@msg `{key}` must be an array `{key}` debe ser una lista @msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats) @msg `{_1}` is no save command `{_1}` no es un comando de guardado @msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación @@ -4559,6 +4582,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0} @msg not an EMF or WMF file no es un archivo EMF ni WMF @msg not an HP-GL file no es un archivo HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos @msg finish editing the pattern first termina primero la edición del motivo @@ -5959,6 +5983,7 @@ mark-style Japanese Japonesas Ruby Ruby Geometric Guides Guías geométricas Geometric Patterns Motivos geométricos + Grit Textures Texturas granuladas Ease In Entrada suave Ease Out Salida suave Ease In Out Entrada y salida suaves @@ -6017,6 +6042,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 +6106,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 @@ -6227,6 +6260,17 @@ axis Y Axis Eje Y Charge Wave Onda de carga Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas + Bristle Brushes Pinceles de cerdas + Fine Liner Delineador fino + Pointed Flat Plano puntiagudo + Stubby Round Redondo corto + Dry Scrub Frotado seco + Soft Dome Cúpula suave + Filbert Lengua de gato + Angled Round Redondo biselado + Angled Flat Plano biselado + Fan Blender Abanico difuminador + Grass Fan Abanico de hierba Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión Save Brush Library… Guardar biblioteca de pinceles… @@ -6437,6 +6481,10 @@ anchor Points: Puntos: @msg features lists at most {max} tags features admite como máximo {max} etiquetas @msg unknown OpenType feature `{t}` función OpenType desconocida `{t}` @msg {label} must be a number above 0 and at most {max} {label} debe ser un número mayor que 0 y como máximo {max} +@msg {label} must be at most {max} characters {label} debe tener como máximo {max} caracteres +@msg {label} must be one character {label} debe ser un solo carácter +@msg at most {max} tab stops como máximo {max} tabulaciones +@msg unknown align `{other}` alineación desconocida `{other}` @msg no character style `{name}` No existe el estilo de carácter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6598,6 +6646,12 @@ breakText Glyphs Glifos Delete the selected brushes? ¿Eliminar los pinceles seleccionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles? Remove Strokes Quitar trazos + Place Brush Art Colocar ilustración del pincel + Replace Brush Art Reemplazar ilustración del pincel + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Algunos trazos usan este pincel. ¿Aplicar la nueva ilustración a esos trazos, o dejarlos como están y añadir una copia del pincel con la nueva ilustración? + Apply to Strokes Aplicar a los trazos + Leave Strokes Dejar los trazos +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no tiene ilustración (solo la tienen los pinceles de dispersión, de arte y de motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Herramientas de organización Arrange in Grid… Organizar en cuadrícula… @@ -6793,6 +6847,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 @@ -6845,6 +6904,19 @@ breakText Glyphs Glifos @plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last` +# Learn panel + Learn Aprender + Explore tutorials Explorar tutoriales + For you Para ti + Skills Habilidades + Get started Primeros pasos + Quick skill guide Guía rápida de habilidades + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriales prácticos breves sobre las herramientas esenciales, agrupados por habilidad. Elige una habilidad para ver sus tutoriales. + List Tutorials Enumerar tutoriales + Show Tutorial Mostrar tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados Deferred Effects Efectos aplazados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 10736a8c4a..fd7a7c62b0 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 @@ -489,6 +493,8 @@ Extrude & Bevel… Pursota ja viistä… 3D (Classic) 3D (perinteinen) Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… + Revolve (Classic)… Pyöräytä (perinteinen)… + Rotate (Classic)… Kierrä (perinteinen)… Surface Pinta Wireframe Rautalanka No Shading Ei varjostusta @@ -559,6 +565,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ä @@ -807,6 +814,14 @@ Damp Paper Kostea paperi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt + Painted and Sketched Maalatut ja luonnostellut + Washed Blue Haalea sininen + Bristle Ochre Harjasokra + Pastel Heather Pastellikanerva + Charcoal Shade Hiilivarjo + Ink Contour Mustekontuuri + Wet Ink Märkä muste + Fine Pen Hieno kynä Gradient Finishes Liukuväriviimeistelyt Shape Effects Muototehosteet Blends and Transparency Sulautukset ja läpinäkyvyys @@ -826,6 +841,8 @@ Spectrum Gradients Spektriliukuvärit Sky Gradients Taivasliukuvärit Neutral Gradients Neutraalit liukuvärit + Organic Fades Orgaaniset häivytykset + Holographic Holografiset Duotone Gradients Kaksisävyliukuvärit Radial Glows Säteittäiset hehkut Save Swatch Library… Tallenna värimallikirjasto… @@ -1961,6 +1978,9 @@ digits Malayalam Malajalam Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle Roman Hanging Punctuation Latinalaiset riippuvat välimerkit + Korean Line Break Anywhere Korean rivinvaihto missä tahansa + East Asian Composition Settings Itäaasialaisen ladonnan asetukset + Korean Composition Settings Korean ladonnan asetukset Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata. Single-line Composer Yhden rivin asettelu Toggle Line Composer Vaihda rivien asettelu @@ -2348,6 +2368,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. @@ -4466,6 +4488,7 @@ imageTrace Outline Ääriviiva @msg `globalAdjust` must be an object `globalAdjust` on oltava objekti @msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5 @msg `stops` must be an array `stops` on oltava taulukko +@msg `{key}` must be an array `{key}` on oltava taulukko @msg `svg` must be an object of SVG options (see document.formats) `svg` on oltava SVG-asetusten objekti (ks. document.formats) @msg `{_1}` is no save command `{_1}` ei ole tallennuskomento @msg `{area}` is no recovery area name `{area}` ei ole palautusalueen nimi @@ -4775,6 +4798,7 @@ imageTrace Outline Ääriviiva @msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0} @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto @msg not an HP-GL file ei HP-GL-tiedosto +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa @msg finish editing the pattern first lopeta ensin kuvion muokkaus @@ -6047,6 +6071,7 @@ swatch Bright Violet Kirkas violetti Ruby Ruby Geometric Guides Geometriset apuviivat Geometric Patterns Geometriset kuviot + Grit Textures Rakeiset tekstuurit Ease In Hidas alku Ease Out Hidas loppu Ease In Out Hidas alku ja loppu @@ -6105,6 +6130,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 +6194,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 @@ -6315,6 +6348,17 @@ axis Y Axis Y-akseli Charge Wave Latausaalto Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet + Bristle Brushes Harjassiveltimet + Fine Liner Ohut viiva + Pointed Flat Suippo litteä + Stubby Round Lyhyt pyöreä + Dry Scrub Kuiva hankaus + Soft Dome Pehmeä kupu + Filbert Kissankieli + Angled Round Vino pyöreä + Angled Flat Vino litteä + Fan Blender Sekoitusviuhka + Grass Fan Ruohoviuhka Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot Save Brush Library… Tallenna sivellinkirjasto… @@ -6524,6 +6568,10 @@ anchor Points: Pisteet: @msg features lists at most {max} tags features sisältää enintään {max} tunnistetta @msg unknown OpenType feature `{t}` tuntematon OpenType-ominaisuus `{t}` @msg {label} must be a number above 0 and at most {max} {label} on oltava luku, joka on yli 0 ja enintään {max} +@msg {label} must be at most {max} characters {label} saa olla enintään {max} merkkiä +@msg {label} must be one character {label} on oltava yksi merkki +@msg at most {max} tab stops enintään {max} sarkainkohtaa +@msg unknown align `{other}` tuntematon tasaus `{other}` @msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6685,6 +6733,12 @@ breakText Glyphs Merkit Delete the selected brushes? Poistetaanko valitut siveltimet? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet? Remove Strokes Poista viivat + Place Brush Art Sijoita siveltimen kuvitus + Replace Brush Art Korvaa siveltimen kuvitus + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Viivat käyttävät tätä sivellintä. Käytetäänkö uutta kuvitusta näissä viivoissa, vai jätetäänkö ne ennalleen ja lisätään siveltimestä kopio, jossa on uusi kuvitus? + Apply to Strokes Käytä viivoihin + Leave Strokes Jätä viivat ennalleen +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ei sisällä kuvitusta (kuvitus on vain sirote-, taide- ja kuviosiveltimissä) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Järjestelytyökalut Arrange in Grid… Järjestä ruudukkoon… @@ -6885,6 +6939,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 @@ -6937,6 +6996,19 @@ breakText Glyphs Merkit @plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last` +# Learn panel + Learn Opi + Explore tutorials Tutustu opetusohjelmiin + For you Sinulle + Skills Taidot + Get started Aloitus + Quick skill guide Pikaopas taitoihin + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Lyhyitä käytännön opetusohjelmia tärkeimmistä työkaluista taidoittain ryhmiteltyinä. Valitse taito nähdäksesi sen opetusohjelmat. + List Tutorials Luettele opetusohjelmat + Show Tutorial Näytä opetusohjelma +@msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) +@msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet Deferred Effects Lykätyt tehosteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c86b39d20a..5c27d7701a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1148,6 +1148,8 @@ font-technology Variable Variables Extrude & Bevel… Extrusion et biseautage… 3D (Classic) 3D (classique) Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… + Revolve (Classic)… Révolution (classique)… + Rotate (Classic)… Rotation (classique)… Surface Surface Wireframe Fil de fer No Shading Sans ombrage @@ -1447,6 +1449,14 @@ font-technology Variable Variables Hair Space Espace ultrafine Hand Tool Outil Main Hand-Drawn Dessiné à la main + Painted and Sketched Peint et esquissé + Washed Blue Bleu lavé + Bristle Ochre Ocre à la brosse + Pastel Heather Bruyère pastel + Charcoal Shade Ombre au fusain + Ink Contour Contour encré + Wet Ink Encre humide + Fine Pen Plume fine Handles Poignées Hard Light Lumière crue Hard Mix Mélange maximal @@ -2348,6 +2358,8 @@ font-technology Variable Variables Options for Selection… Options de la sélection… Options: Options : Options… Options… + Organic Fades Fondus organiques + Holographic Holographiques or more spaces with a tab espaces ou plus par une tabulation Order: Ordre : Ordinals Ordinaux @@ -2539,6 +2551,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 +2644,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 +2827,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 @@ -2890,6 +2909,9 @@ digits Malayalam Malayalam Rise… Élévation… Roman Baseline Ligne de base romaine Roman Hanging Punctuation Ponctuation romaine hors justification + Korean Line Break Anywhere Saut de ligne coréen n’importe où + East Asian Composition Settings Paramètres de composition d’Asie orientale + Korean Composition Settings Paramètres de composition coréenne Rotate Rotation Rotate 90° CW Rotation de 90° horaire Rotate Tool Outil Rotation @@ -4232,6 +4254,7 @@ axis Both Les deux @msg `globalAdjust` must be an object `globalAdjust` doit être un objet @msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5 @msg `stops` must be an array `stops` doit être un tableau +@msg `{key}` must be an array `{key}` doit être un tableau @msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats) @msg `{_1}` is no save command `{_1}` n’est pas une commande d’enregistrement @msg `{area}` is no recovery area name `{area}` n’est pas un nom de zone de récupération @@ -4548,6 +4571,7 @@ axis Both Les deux @msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF @msg not an HP-GL file ce n’est pas un fichier HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif @msg finish editing the pattern first terminez d’abord la modification du motif @@ -6012,6 +6036,7 @@ theme Appearance Apparence Ruby Ruby Geometric Guides Repères géométriques Geometric Patterns Motifs géométriques + Grit Textures Textures granuleuses Ease In Accélération progressive Ease Out Décélération progressive Ease In Out Accélération et décélération @@ -6070,6 +6095,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 +6159,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é @@ -6280,6 +6313,17 @@ axis Y Axis Axe Y Charge Wave Vague de charge Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques + Bristle Brushes Pinceaux à soies + Fine Liner Traceur fin + Pointed Flat Plat pointu + Stubby Round Rond court + Dry Scrub Frottis sec + Soft Dome Dôme doux + Filbert Langue de chat + Angled Round Rond biseauté + Angled Flat Plat biseauté + Fan Blender Éventail estompeur + Grass Fan Éventail à herbe Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion Save Brush Library… Enregistrer la bibliothèque de formes… @@ -6489,6 +6533,10 @@ anchor Points: Points : @msg features lists at most {max} tags features contient au plus {max} balises @msg unknown OpenType feature `{t}` fonction OpenType inconnue `{t}` @msg {label} must be a number above 0 and at most {max} {label} doit être un nombre supérieur à 0 et au plus égal à {max} +@msg {label} must be at most {max} characters {label} doit compter au plus {max} caractères +@msg {label} must be one character {label} doit être un seul caractère +@msg at most {max} tab stops au plus {max} taquets de tabulation +@msg unknown align `{other}` alignement inconnu `{other}` @msg no character style `{name}` Style de caractère `{name}` introuvable # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6650,6 +6698,12 @@ breakText Glyphs Glyphes Delete the selected brushes? Supprimer les formes sélectionnées ? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ? Remove Strokes Supprimer les contours + Place Brush Art Placer l’illustration de la forme + Replace Brush Art Remplacer l’illustration de la forme + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Des contours utilisent cette forme. Appliquer la nouvelle illustration à ces contours, ou les laisser tels quels et ajouter une copie de la forme avec la nouvelle illustration ? + Apply to Strokes Appliquer aux contours + Leave Strokes Conserver les contours +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` n’a pas d’illustration (seules les formes diffuses, artistiques et de motif en ont) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Outils de disposition Arrange in Grid… Disposer en grille… @@ -6845,6 +6899,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 @@ -6897,6 +6956,19 @@ breakText Glyphs Glyphes @plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées @msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last` +# Learn panel + Learn Apprendre + Explore tutorials Découvrir les tutoriels + For you Pour vous + Skills Compétences + Get started Prise en main + Quick skill guide Guide rapide des compétences + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Courts tutoriels pratiques sur les outils essentiels, regroupés par compétence. Choisissez une compétence pour voir ses tutoriels. + List Tutorials Lister les tutoriels + Show Tutorial Afficher le tutoriel +@msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés Deferred Effects Effets différés diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9f9493d4cc..ac6a024884 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -691,6 +691,17 @@ antiAlias Sharp शार्प Calligraphic Brush कैलिग्राफिक ब्रश Calligraphic Brush Options कैलिग्राफिक ब्रश ऑप्शन्स Calligraphic Nibs कैलिग्राफिक निब्स + Bristle Brushes ब्रिसल ब्रश संग्रह + Fine Liner बारीक लाइनर + Pointed Flat नुकीला फ्लैट + Stubby Round छोटा गोल + Dry Scrub सूखी रगड़ + Soft Dome मुलायम गुंबद + Filbert फिल्बर्ट + Angled Round तिरछा गोल + Angled Flat तिरछा फ्लैट + Fan Blender मिश्रण पंखा + Grass Fan घास पंखा Can't export “{name}”. "{name}" निर्यात नहीं कर सकते। Can't open “{name}”. "{name}" नहीं खोल सकते हैं। Can't print these settings: see the Summary इन सेटिंग्स को मुद्रित नहीं कर सकते: सारांश देखें @@ -1745,6 +1756,8 @@ antiAlias Sharp शार्प Extrude & Bevel… एक्सट्रूडेड और बेवेल… 3D (Classic) 3D (क्लासिक) Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… + Revolve (Classic)… रिवॉल्व (क्लासिक)… + Rotate (Classic)… घुमाव (क्लासिक)… Surface सतह Wireframe वायरफ़्रेम No Shading कोई शेडिंग नहीं @@ -2073,6 +2086,7 @@ antiAlias Sharp शार्प Gridline every ग्रिडलाइन हर Gridline every: ग्रिडलाइन हर: Grids In Back ग्रिड में वापस + Grit Textures दानेदार टेक्सचर Group समूह Group Overlapping Objects समूह ओवरलैपिंग ऑब्जेक्ट Group Selection समूह सिलेक्शन @@ -2100,6 +2114,14 @@ antiAlias Sharp शार्प Hand हैंड Hand Tool हैंड टूल Hand-Drawn हाथ-ड्रन + Painted and Sketched पेंट और स्केच + Washed Blue धुला नीला + Bristle Ochre ब्रश गेरू + Pastel Heather पेस्टल बैंगनी + Charcoal Shade चारकोल छाया + Ink Contour स्याही रूपरेखा + Wet Ink गीली स्याही + Fine Pen बारीक पेन Handles हैंडल्स Hard कठोर Hard Light हार्ड लाइट @@ -3173,6 +3195,8 @@ antiAlias Sharp शार्प Options for Selection… सिलेक्शन के लिए ऑप्शन्स… Options: ऑप्शन्स: Options… ऑप्शन्स… + Organic Fades ऑर्गेनिक फ़ेड + Holographic होलोग्राफ़िक or more spaces with a tab या एक टैब के साथ अधिक रिक्त स्थान Orange नारंगी Order: आदेश: @@ -3501,6 +3525,7 @@ antiAlias Sharp शार्प Press Quality प्रेस गुणवत्ता Pressure दबाव Preview प्रीव्यू + Preview: प्रीव्यू: Preview (1 inch on the page) प्रीव्यू (1 इंच पृष्ठ पर) Preview All Layers सभी लेयर्स का प्रीव्यू करें Preview in Browser ब्राउज़र में प्रीव्यू @@ -3604,6 +3629,8 @@ antiAlias Sharp शार्प Range: रेंज: Raster Effects रीस्टर इफ़ेक्ट Raster File Format: रास्टर फ़ाइल फ़ॉर्मैट: + Include Placed Images रखी गई छवियाँ शामिल करें + Include Fill Hatches फ़िल हैच शामिल करें Raster Sample Size: रास्टर सैंपल साइज़: Raster/Vector Balance: रास्टर/वेक्टर बैलेंस: Rasterize रास्टराइज़ करें @@ -3766,6 +3793,12 @@ antiAlias Sharp शार्प Remove Selected Assets सिलेक्टेड परिसंपत्तियों को हटा दें Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें Remove Strokes स्ट्रोक हटाएँ + Place Brush Art ब्रश आर्टवर्क रखें + Replace Brush Art ब्रश आर्टवर्क बदलें + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? स्ट्रोक्स इस ब्रश का उपयोग कर रहे हैं। नया आर्टवर्क उन स्ट्रोक्स पर लागू करें, या उन्हें जैसा है वैसा छोड़कर नए आर्टवर्क वाले ब्रश की एक कॉपी जोड़ें? + Apply to Strokes स्ट्रोक्स पर लागू करें + Leave Strokes स्ट्रोक्स जैसे हैं वैसे छोड़ें +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` में कोई आर्टवर्क नहीं है (आर्टवर्क केवल स्कैटर, आर्ट और पैटर्न ब्रश में होता है) Remove the shortcut शॉर्टकट निकालें Remove Threading थ्रेडिंग निकालें Remove White Overprint व्हाइट ओवरप्रिंट निकालें @@ -3809,6 +3842,10 @@ antiAlias Sharp शार्प Replace Missing Fonts अनुपलब्ध फ़ॉन्ट रिप्लेस करें Replace Missing Fonts… अनुपलब्ध फ़ॉन्ट रिप्लेस करें… Replace Spine स्पाइन रिप्लेस करें + Create Spine स्पाइन बनाएँ + Create Spine: स्पाइन बनाएँ: + Always हमेशा + Never कभी नहीं Replace Symbol सिंबल रिप्लेस करें Reset Transformation रूपांतरण रीसेट करें Replace With Font इस फ़ॉन्ट से रिप्लेस करें @@ -3939,6 +3976,9 @@ antiAlias Sharp शार्प Roman रोमन Roman Baseline रोमन बेसलाइन Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न + Korean Line Break Anywhere कोरियाई लाइन ब्रेक कहीं भी + East Asian Composition Settings पूर्वी एशियाई संरचना सेटिंग्स + Korean Composition Settings कोरियाई संरचना सेटिंग्स Rotate घुमाएँ Rotate 90° CW 90° सीडब्ल्यू Rotate Tool रोटेट टूल @@ -5375,6 +5415,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 +5475,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` बताता है: कुछ भी इंस्टॉल नहीं हुआ @@ -5530,6 +5577,9 @@ antiAlias Sharp शार्प @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए +@msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए +@msg {label} must be one character {label} एक वर्ण होना चाहिए +@msg at most {max} tab stops अधिकतम {max} टैब स्टॉप @msg {label} must be a number at most {max} {label} सबसे {max} पर एक नंबर होना चाहिए @msg {label} must be a number from -1296 to 1296 {label} को -1296 से 1296 के बीच की संख्या होना चाहिए @msg {label} must be a number from {min} to {max} {label} को {min} से {max} तक एक नंबर होना चाहिए @@ -5885,6 +5935,7 @@ antiAlias Sharp शार्प @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @msg `stop` must be a stop index `stop` एक स्टॉप इंडेक्स होना चाहिए @msg `stops` must be an array `stops` एक सारणी होना चाहिए +@msg `{key}` must be an array `{key}` एक सारणी होना चाहिए @msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें @msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें @msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true) @@ -6392,7 +6443,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 प्रति के लिए हर नंबर का नाम लिया जाता है @@ -7107,6 +7158,7 @@ antiAlias Sharp शार्प @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) @@ -8261,6 +8313,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 सुरक्षित मोड @@ -8337,6 +8394,19 @@ theme Appearance अपीयरेंस @plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं @msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में +# Learn panel + Learn सीखें + Explore tutorials ट्यूटोरियल देखें + For you आपके लिए + Skills कौशल + Get started शुरू करें + Quick skill guide त्वरित कौशल गाइड + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. आवश्यक टूल के लिए छोटे व्यावहारिक ट्यूटोरियल, कौशल के अनुसार समूहित। किसी कौशल के ट्यूटोरियल देखने के लिए उसे चुनें। + List Tutorials ट्यूटोरियल की सूची + Show Tutorial ट्यूटोरियल दिखाएँ +@msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) +@msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) + # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें Deferred Effects स्थगित इफ़ेक्ट्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eca13575c2..6debe27443 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1444,6 +1444,8 @@ font-technology Variable Változó Extrude & Bevel… Kihúzás és élletörés… 3D (Classic) 3D (klasszikus) Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… + Revolve (Classic)… Forgatás tengely körül (klasszikus)… + Rotate (Classic)… Elforgatás (klasszikus)… Surface Felület Wireframe Drótváz No Shading Nincs árnyékolás @@ -1847,6 +1849,14 @@ file-filter Templates Sablonok Hand Kéz Hand Tool Kéz Hand-Drawn Kézzel rajzolt + Painted and Sketched Festett és vázlatos + Washed Blue Kimosott kék + Bristle Ochre Sörteokker + Pastel Heather Pasztell hanga + Charcoal Shade Szénárnyék + Ink Contour Tuskontúr + Wet Ink Nedves tus + Fine Pen Finom toll Handles Fogantyúk Hard Szigorú Hard Light Kemény fény @@ -3095,6 +3105,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 +3184,8 @@ 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 + Holographic Holografikus Radial Gradient Körkörös színátmenet Conic Gradient Kúpos színátmenet Diamond Gradient Rombusz színátmenet @@ -3191,6 +3204,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 +3375,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 @@ -3473,6 +3492,9 @@ mark-style Japanese Japán Roman Latin Roman Baseline Latin alapvonal Roman Hanging Punctuation Latin függő írásjelek + Korean Line Break Anywhere Koreai sortörés bárhol + East Asian Composition Settings Kelet-ázsiai szedési beállítások + Korean Composition Settings Koreai szedési beállítások Rotate Elforgatás Rotate 90° CW Forgatás 90°-kal jobbra Rotate Tool Elforgatás @@ -4751,6 +4773,7 @@ imageTrace Outline Körvonal @msg `globalAdjust` must be an object `globalAdjust`: objektum kell @msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell @msg `stops` must be an array `stops`: tömb kell +@msg `{key}` must be an array `{key}`: tömb kell @msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats) @msg `to` must be "corner" or "smooth" `to`: "corner" vagy "smooth" kell @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`: "emBox", "capHeight", "xHeight" vagy "icfBox" kell @@ -5244,6 +5267,7 @@ imageTrace Outline Körvonal @msg not a blend nem összemosás @msg not an EMF or WMF file nem EMF- vagy WMF-fájl @msg not an HP-GL file nem HP-GL-fájl +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy @msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését @@ -6000,6 +6024,7 @@ imageTrace Outline Körvonal Ruby Ruby Geometric Guides Geometriai segédvonalak Geometric Patterns Geometriai mintázatok + Grit Textures Szemcsés textúrák Ease In Lassú kezdés Ease Out Lassú befejezés Ease In Out Lassú kezdés és befejezés @@ -6058,6 +6083,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 +6147,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 @@ -6268,6 +6301,17 @@ axis Y Axis Y tengely Charge Wave Töltéshullám Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek + Bristle Brushes Sörteecsetek + Fine Liner Finom kontúr + Pointed Flat Hegyes lapos + Stubby Round Rövid kerek + Dry Scrub Száraz dörzsölés + Soft Dome Lágy kupola + Filbert Macskanyelv + Angled Round Ferde kerek + Angled Flat Ferde lapos + Fan Blender Elmosó legyező + Grass Fan Fűlegyező Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok Save Brush Library… Ecsetkönyvtár mentése… @@ -6478,6 +6522,10 @@ anchor Points: Pontok: @msg features lists at most {max} tags a features legfeljebb {max} címkét tartalmazhat @msg unknown OpenType feature `{t}` ismeretlen OpenType-funkció: `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0-nál nagyobb és legfeljebb {max} értékű szám kell legyen +@msg {label} must be at most {max} characters {label} legfeljebb {max} karakter lehet +@msg {label} must be one character {label} pontosan egy karakter kell legyen +@msg at most {max} tab stops legfeljebb {max} tabulátor +@msg unknown align `{other}` ismeretlen igazítás: `{other}` @msg no character style `{name}` A(z) `{name}` karakterstílus nem található # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6639,6 +6687,12 @@ breakText Glyphs Glifák Delete the selected brushes? Törlöd a kijelölt ecseteket? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket? Remove Strokes Körvonalak eltávolítása + Place Brush Art Ecsetgrafika elhelyezése + Replace Brush Art Ecsetgrafika cseréje + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Körvonalak használják ezt az ecsetet. Alkalmazod az új grafikát ezekre a körvonalakra, vagy változatlanul hagyod őket, és hozzáadod az ecset egy másolatát az új grafikával? + Apply to Strokes Alkalmazás a körvonalakra + Leave Strokes Körvonalak megtartása +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ecsetnek nincs grafikája (csak a szóró-, a grafikus és a mintázatecseteknek van) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Elrendezőeszközök Arrange in Grid… Elrendezés rácsba… @@ -6834,6 +6888,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 @@ -6886,6 +6945,19 @@ breakText Glyphs Glifák @plural {n} line cut|{n} lines cut {n} sor levágva @msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt +# Learn panel + Learn Tanulás + Explore tutorials Oktatóanyagok felfedezése + For you Önnek + Skills Készségek + Get started Első lépések + Quick skill guide Gyors készségkalauz + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Rövid gyakorlati oktatóanyagok az alapvető eszközökhöz, készségek szerint csoportosítva. Válasszon egy készséget az oktatóanyagai megtekintéséhez. + List Tutorials Oktatóanyagok listázása + Show Tutorial Oktatóanyag megjelenítése +@msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) +@msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) + # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése Deferred Effects Elhalasztott effektusok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7669594925..c272bc54f9 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 @@ -450,6 +454,8 @@ Extrude & Bevel… Ekstrusi & bevel… 3D (Classic) 3D (klasik) Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… + Revolve (Classic)… Revolusi (klasik)… + Rotate (Classic)… Putar (klasik)… Surface Permukaan Wireframe Kerangka kawat No Shading Tanpa pengarsiran @@ -520,6 +526,7 @@ Gaussian Blur… Kabur Gaussian… View Tampilan Preview Pratinjau + Preview: Pratinjau: Overprint Preview Pratinjau overprint Pixel Preview Pratinjau piksel Trim View Tampilan terpangkas @@ -753,6 +760,14 @@ Damp Paper Kertas Basah Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan + Painted and Sketched Lukisan dan sketsa + Washed Blue Biru luntur + Bristle Ochre Oker kuas kering + Pastel Heather Ungu pastel + Charcoal Shade Bayangan arang + Ink Contour Kontur tinta + Wet Ink Tinta basah + Fine Pen Pena halus Gradient Finishes Finishing gradien Shape Effects Efek bentuk Blends and Transparency Blend dan transparansi @@ -772,6 +787,8 @@ Spectrum Gradients Gradien spektrum Sky Gradients Gradien langit Neutral Gradients Gradien netral + Organic Fades Pudar organik + Holographic Holografik Duotone Gradients Gradien duotone Radial Glows Pendar radial Save Swatch Library… Simpan pustaka sampel warna… @@ -1806,6 +1823,9 @@ digits Malayalam Malayalam Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri Roman Hanging Punctuation Tanda baca gantung Romawi + Korean Line Break Anywhere Putus baris Korea di mana saja + East Asian Composition Settings Pengaturan komposisi Asia Timur + Korean Composition Settings Pengaturan komposisi Korea Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. Single-line Composer Komposer satu baris Toggle Line Composer Alihkan komposer baris @@ -2192,6 +2212,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. @@ -4492,6 +4514,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0} @msg not an EMF or WMF file bukan berkas EMF atau WMF @msg not an HP-GL file bukan berkas HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` harus berupa array berisi 1 sampai 255 warna #rrggbb @msg not found tidak ditemukan @msg not in pattern editing mode tidak dalam mode pengeditan pola @msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu @@ -5851,6 +5874,7 @@ swatch Bright Violet Violet cerah Ruby Ruby Geometric Guides Panduan Geometris Geometric Patterns Pola Geometris + Grit Textures Tekstur Butiran Ease In Awal Lambat Ease Out Akhir Lambat Ease In Out Awal dan Akhir Lambat @@ -5904,6 +5928,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 @@ -6049,6 +6074,17 @@ axis Y Axis Sumbu Y Charge Wave Gelombang Muatan Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi + Bristle Brushes Kuas-kuas bristle + Fine Liner Garis halus + Pointed Flat Pipih runcing + Stubby Round Bulat pendek + Dry Scrub Gosok kering + Soft Dome Kubah lembut + Filbert Pipih oval + Angled Round Bulat miring + Angled Flat Pipih miring + Fan Blender Kipas pembaur + Grass Fan Kipas rumput Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar Save Brush Library… Simpan pustaka kuas… @@ -6392,6 +6428,12 @@ breakText Glyphs Glif Delete the selected brushes? Hapus kuas yang dipilih? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya? Remove Strokes Hapus Stroke + Place Brush Art Tempatkan karya seni kuas + Replace Brush Art Ganti karya seni kuas + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Stroke menggunakan kuas ini. Terapkan karya seni baru ke stroke tersebut, atau biarkan apa adanya dan tambahkan salinan kuas dengan karya seni baru? + Apply to Strokes Terapkan ke Stroke + Leave Strokes Biarkan Stroke +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` tidak memiliki karya seni (hanya kuas sebar, kuas seni, dan kuas pola yang memilikinya) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alat penyusunan Arrange in Grid… Susun dalam grid… @@ -6574,6 +6616,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 @@ -6621,6 +6668,19 @@ breakText Glyphs Glif @plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang @plural {n} line cut|{n} lines cut {n} baris dipotong +# Learn panel + Learn Belajar + Explore tutorials Jelajahi tutorial + For you Untuk Anda + Skills Keterampilan + Get started Memulai + Quick skill guide Panduan cepat keterampilan + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorial praktik singkat untuk alat-alat utama, dikelompokkan menurut keterampilan. Pilih keterampilan untuk melihat tutorialnya. + List Tutorials Daftar tutorial + Show Tutorial Tampilkan tutorial +@msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) + # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda Deferred Effects Efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bc9fb483c9..75c13cd6a6 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 @@ -1154,6 +1162,8 @@ font-technology Variable Variabili Extrude & Bevel… Estrusione e smusso… 3D (Classic) 3D (classico) Extrude & Bevel (Classic)… Estrusione e smusso (classico)… + Revolve (Classic)… Rivoluzione (classico)… + Rotate (Classic)… Ruota (classico)… Surface Superficie Wireframe Reticolo No Shading Nessuna ombreggiatura @@ -1466,6 +1476,14 @@ file-filter Templates Modelli Hair Space Spazio sottilissimo Hand Tool Strumento Mano Hand-Drawn Disegnato a mano + Painted and Sketched Dipinto e schizzato + Washed Blue Blu slavato + Bristle Ochre Ocra a setole + Pastel Heather Erica pastello + Charcoal Shade Ombra a carboncino + Ink Contour Contorno a china + Wet Ink China bagnata + Fine Pen Pennino fine Handles Maniglie Hard Light Luce intensa Hard Mix Miscela intensa @@ -2386,6 +2404,8 @@ file-filter Templates Modelli Options for Selection… Opzioni per la selezione… Options: Opzioni: Options… Opzioni… + Organic Fades Dissolvenze organiche + Holographic Olografici or more spaces with a tab o più spazi con una tabulazione Order: Ordine: Ordinals Ordinali @@ -2579,6 +2599,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 +2694,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 +2877,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 @@ -2933,6 +2960,9 @@ digits Malayalam Malayalam Rise… Ascesa… Roman Baseline Linea di base romana Roman Hanging Punctuation Punteggiatura sporgente romana + Korean Line Break Anywhere Interruzione di riga coreana ovunque + East Asian Composition Settings Impostazioni di composizione dell’Asia orientale + Korean Composition Settings Impostazioni di composizione coreana Rotate Ruota Rotate 90° CW Ruota di 90° in senso orario Rotate Tool Strumento Ruota @@ -4286,6 +4316,7 @@ axis Both Entrambi @msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5 @msg `stops` must be an array `stops` deve essere un array +@msg `{key}` must be an array `{key}` deve essere un array @msg `svg` must be an object of SVG options (see document.formats) `svg` deve essere un oggetto di opzioni SVG (vedi document.formats) @msg `{_1}` is no save command `{_1}` non è un comando di salvataggio @msg `{area}` is no recovery area name `{area}` non è il nome di un'area di recupero @@ -4602,6 +4633,7 @@ axis Both Entrambi @msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0} @msg not an EMF or WMF file non è un file EMF o WMF @msg not an HP-GL file non è un file HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo @msg finish editing the pattern first termina prima la modifica del motivo @@ -5632,6 +5664,7 @@ lighting Light Luce Ruby Ruby Geometric Guides Guide geometriche Geometric Patterns Motivi geometrici + Grit Textures Texture granulose Ease In Inizio graduale Ease Out Fine graduale Ease In Out Inizio e fine graduali @@ -5692,6 +5725,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 +5789,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 @@ -5902,6 +5943,17 @@ axis Y Axis Asse Y Charge Wave Onda di carica Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici + Bristle Brushes Pennelli a setole + Fine Liner Tratto fine + Pointed Flat Piatto appuntito + Stubby Round Tondo corto + Dry Scrub Strofinato secco + Soft Dome Cupola morbida + Filbert Lingua di gatto + Angled Round Tondo obliquo + Angled Flat Piatto obliquo + Fan Blender Ventaglio sfumino + Grass Fan Ventaglio per erba Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione Save Brush Library… Salva libreria pennelli… @@ -6114,6 +6166,10 @@ anchor Points: Punti: @msg features lists at most {max} tags features contiene al massimo {max} tag @msg unknown OpenType feature `{t}` funzione OpenType sconosciuta `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve essere un numero maggiore di 0 e al massimo {max} +@msg {label} must be at most {max} characters {label} deve avere al massimo {max} caratteri +@msg {label} must be one character {label} deve essere un solo carattere +@msg at most {max} tab stops al massimo {max} tabulazioni +@msg unknown align `{other}` allineamento sconosciuto `{other}` @msg no character style `{name}` Stile di carattere `{name}` non trovato # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6276,6 +6332,12 @@ breakText Glyphs Glifi Delete the selected brushes? Eliminare i pennelli selezionati? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli? Remove Strokes Rimuovi tracce + Place Brush Art Inserisci grafica del pennello + Replace Brush Art Sostituisci grafica del pennello + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Alcune tracce usano questo pennello. Applicare la nuova grafica a queste tracce, o lasciarle come sono e aggiungere una copia del pennello con la nuova grafica? + Apply to Strokes Applica alle tracce + Leave Strokes Lascia le tracce +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` non ha una grafica (ce l’hanno solo i pennelli a dispersione, artistici e motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Strumenti di disposizione Arrange in Grid… Disponi in griglia… @@ -6471,6 +6533,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 @@ -6523,6 +6590,19 @@ breakText Glyphs Glifi @plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last` +# Learn panel + Learn Impara + Explore tutorials Esplora i tutorial + For you Per te + Skills Competenze + Get started Per iniziare + Quick skill guide Guida rapida alle competenze + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Brevi tutorial pratici sugli strumenti essenziali, raggruppati per competenza. Scegli una competenza per vederne i tutorial. + List Tutorials Elenca tutorial + Show Tutorial Mostra tutorial +@msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) + # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati Deferred Effects Effetti rimandati diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bf6cd242e3..3d4ee28d51 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 エンベロープ @@ -527,6 +531,8 @@ digits Malayalam マラヤーラム数字 Extrude & Bevel… 押し出しとベベル… 3D (Classic) 3D(クラシック) Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… + Revolve (Classic)… 回転体(クラシック)… + Rotate (Classic)… 回転(クラシック)… Surface 表面 Wireframe ワイヤーフレーム No Shading 陰影なし @@ -840,6 +846,14 @@ digits Malayalam マラヤーラム数字 Damp Paper 湿った紙 Outlines and Rules アウトラインと罫線 Hand-Drawn 手描き風 + Painted and Sketched 絵画とスケッチ + Washed Blue にじみブルー + Bristle Ochre 筆跡オーカー + Pastel Heather パステルヘザー + Charcoal Shade 木炭シェード + Ink Contour インク輪郭 + Wet Ink 濡れ墨 + Fine Pen 細いペン Gradient Finishes グラデーション仕上げ Shape Effects シェイプ効果 Blends and Transparency ブレンドと透明 @@ -859,6 +873,8 @@ digits Malayalam マラヤーラム数字 Spectrum Gradients スペクトルグラデーション Sky Gradients 空のグラデーション Neutral Gradients ニュートラルグラデーション + Organic Fades オーガニックフェード + Holographic ホログラフィック Duotone Gradients ダブルトーングラデーション Radial Glows 円形グロー Save Swatch Library… スウォッチライブラリを保存… @@ -870,6 +886,7 @@ digits Malayalam マラヤーラム数字 Search Commands… コマンドを検索… VectorCraft Help… VectorCraft ヘルプ… Preview プレビュー + Preview: プレビュー: Show Edges エッジを表示 Show Corner Widget コーナーウィジェットを表示 Show Template テンプレートを表示 @@ -1459,6 +1476,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 +2742,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 非対応のアートをラスタライズ @@ -2919,6 +2946,9 @@ mark-style Japanese 日本式 Right Plane 右面 Right: 右: Roman Hanging Punctuation 欧文ぶら下がり + Korean Line Break Anywhere 韓国語の任意位置での改行 + East Asian Composition Settings 東アジア言語の組版設定 + Korean Composition Settings 韓国語の組版設定 Rotate 回転 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール @@ -4384,6 +4414,7 @@ swatch Bright Violet ビビッドバイオレット @msg `preserve` must be an object `preserve` にはオブジェクトを指定してください @msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください @msg `stops` must be an array `stops` には配列を指定してください +@msg `{key}` must be an array `{key}` には配列を指定してください @msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください @msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です @msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください @@ -4672,6 +4703,7 @@ swatch Bright Violet ビビッドバイオレット @msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません @msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません @msg not an HP-GL file HP-GL ファイルではありません +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください @msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません @msg the EMF header is damaged EMF ヘッダーが破損しています @msg damaged 破損しています @@ -5682,6 +5714,7 @@ lighting Light ライト Ruby ルビ Geometric Guides 幾何ガイド Geometric Patterns 幾何パターン + Grit Textures 粒状テクスチャ Ease In イーズイン Ease Out イーズアウト Ease In Out イーズインアウト @@ -5757,6 +5790,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 +5854,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` の値と一致しません。何もインストールされませんでした @@ -5967,6 +6008,17 @@ axis Y Axis Y 軸 Charge Wave チャージウェーブ Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 + Bristle Brushes 剛毛ブラシ集 + Fine Liner 細線ライナー + Pointed Flat 先細平筆 + Stubby Round 短丸筆 + Dry Scrub ドライスクラブ + Soft Dome ソフトドーム + Filbert フィルバート + Angled Round 斜め丸筆 + Angled Flat 斜め平筆 + Fan Blender ぼかし扇筆 + Grass Fan 草描き扇筆 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ Save Brush Library… ブラシライブラリを保存… @@ -6179,6 +6231,10 @@ anchor Points: ポイント: @msg features lists at most {max} tags features に指定できるタグは {max} 個までです @msg unknown OpenType feature `{t}` 不明な OpenType 機能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} には 0 より大きく {max} 以下の数値を指定してください +@msg {label} must be at most {max} characters {label} には {max} 文字以内で指定してください +@msg {label} must be one character {label} には 1 文字を指定してください +@msg at most {max} tab stops タブストップは {max} 個までです +@msg unknown align `{other}` 不明な揃え `{other}` @msg no character style `{name}` 文字スタイル `{name}` が見つかりません # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6341,6 +6397,12 @@ breakText Glyphs 字形 Delete the selected brushes? 選択したブラシを削除しますか? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか? Remove Strokes 線を削除 + Place Brush Art ブラシのアートを配置 + Replace Brush Art ブラシのアートを置き換え + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? このブラシは線で使われています。新しいアートをそれらの線に適用しますか?それとも線をそのままにして、新しいアートを持つブラシのコピーを追加しますか? + Apply to Strokes 線に適用 + Leave Strokes 線を保持 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` にはアートがありません (アートを持つのは散布ブラシ、アートブラシ、パターンブラシだけです) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 配置ツール Arrange in Grid… グリッドに配置… @@ -6536,6 +6598,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 セーフモード @@ -6588,6 +6655,19 @@ breakText Glyphs 字形 @plural {n} line cut|{n} lines cut {n} 行を切り詰め @msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください +# Learn panel + Learn 学ぶ + Explore tutorials チュートリアルを見る + For you おすすめ + Skills スキル + Get started はじめに + Quick skill guide クイックスキルガイド + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 基本ツールを学ぶ短い実践チュートリアルを、スキルごとにまとめています。スキルを選ぶとチュートリアルが表示されます。 + List Tutorials チュートリアルの一覧 + Show Tutorial チュートリアルを表示 +@msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) +@msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) + # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 Deferred Effects 保留中の効果 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 56aee27b6e..b6e2363dc4 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 გარსით დეფორმაცია @@ -418,6 +422,8 @@ Extrude & Bevel… ამოწევა და წაკვეთა… 3D (Classic) 3D (კლასიკური) Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… + Revolve (Classic)… ბრუნვა (კლასიკური)… + Rotate (Classic)… შემობრუნება (კლასიკური)… Surface ზედაპირი Wireframe მავთულის ჩარჩო No Shading დაჩრდილვის გარეშე @@ -495,6 +501,7 @@ Gaussian Blur… გაუსის გაბუნდოვნება… View ხედი Preview წინასწარი ნახვა + Preview: წინასწარი ნახვა: Overprint Preview ზედდაბეჭდვის წინასწარი ნახვა Pixel Preview პიქსელების წინასწარი ნახვა Trim View მოჭრილი ხედი @@ -713,6 +720,14 @@ Damp Paper ნესტიანი ქაღალდი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული + Painted and Sketched დახატული და ესკიზური + Washed Blue გარეცხილი ლურჯი + Bristle Ochre ფუნჯის ოხრა + Pastel Heather პასტელის იისფერი + Charcoal Shade ნახშირის ჩრდილი + Ink Contour მელნის კონტური + Wet Ink სველი მელანი + Fine Pen წვრილი კალამი Gradient Finishes გრადიენტული ზედაპირები Shape Effects ფიგურის ეფექტები Blends and Transparency გადასვლები და გამჭვირვალობა @@ -732,6 +747,8 @@ Spectrum Gradients სპექტრის გრადიენტები Sky Gradients ცის გრადიენტები Neutral Gradients ნეიტრალური გრადიენტები + Organic Fades ორგანული გაქრობები + Holographic ჰოლოგრაფიული Duotone Gradients ორტონიანი გრადიენტები Radial Glows რადიალური ნათებები Save Swatch Library… ფერის ნიმუშების ბიბლიოთეკის შენახვა… @@ -1732,6 +1749,9 @@ digits Malayalam მალაიალამური Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Korean Line Break Anywhere კორეული სტრიქონის გადატანა ნებისმიერ ადგილას + East Asian Composition Settings აღმოსავლეთ აზიური აწყობის პარამეტრები + Korean Composition Settings კორეული აწყობის პარამეტრები Select a text object to edit its paragraph attributes. აბზაცის ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. Single-line Composer სტრიქონის მიხედვით აწყობა Toggle Line Composer აწყობის რეჟიმის გადართვა @@ -2115,6 +2135,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. ტექსტი ტექსტად რჩება, ყოველი კონტური კი ხაზია თავისი სისქითა და ტირეებით. @@ -4025,6 +4047,7 @@ font-technology Variable ცვლადი @msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი @msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5 @msg `stops` must be an array `stops` უნდა იყოს მასივი +@msg `{key}` must be an array `{key}` უნდა იყოს მასივი @msg `svg` must be an object of SVG options (see document.formats) `svg` უნდა იყოს SVG-ის პარამეტრების ობიექტი (იხ. document.formats) @msg `{_1}` is no save command `{_1}` შენახვის ბრძანება არ არის @msg `{area}` is no recovery area name `{area}` აღდგენის არის სახელი არ არის @@ -4319,6 +4342,7 @@ font-technology Variable ცვლადი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not an HP-GL file ეს არ არის HP-GL ფაილი +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული @msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება @@ -5957,6 +5981,7 @@ imageTrace Outline კონტური Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები Geometric Patterns გეომეტრიული პატერნები + Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი Ease Out ნელი დასასრული Ease In Out ნელი დასაწყისი და დასასრული @@ -6026,6 +6051,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 +6115,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: არაფერი დაყენებულა @@ -6236,6 +6269,17 @@ axis Y Axis Y ღერძი Charge Wave მუხტის ტალღა Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები + Bristle Brushes ჯაგრის ფუნჯები + Fine Liner წვრილი ხაზი + Pointed Flat წაწვეტებული ბრტყელი + Stubby Round მოკლე მრგვალი + Dry Scrub მშრალი ხეხვა + Soft Dome რბილი გუმბათი + Filbert ოვალური ბრტყელი + Angled Round დახრილი მრგვალი + Angled Flat დახრილი ბრტყელი + Fan Blender შერევის მარაო + Grass Fan ბალახის მარაო Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @@ -6520,6 +6564,10 @@ anchor Points: წერტილები: @msg features lists at most {max} tags features შეიძლება შეიცავდეს არაუმეტეს {max} ტეგს @msg unknown OpenType feature `{t}` უცნობი OpenType შესაძლებლობა `{t}` @msg {label} must be a number above 0 and at most {max} {label} უნდა იყოს 0-ზე მეტი და არაუმეტეს {max}-ისა რიცხვი +@msg {label} must be at most {max} characters {label} უნდა შეიცავდეს არაუმეტეს {max} სიმბოლოს +@msg {label} must be one character {label} უნდა იყოს ერთი სიმბოლო +@msg at most {max} tab stops არაუმეტეს {max} ტაბულაციის გაჩერება +@msg unknown align `{other}` უცნობი სწორება `{other}` @msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6681,6 +6729,12 @@ breakText Glyphs გლიფები Delete the selected brushes? წაიშალოს არჩეული ფუნჯები? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები? Remove Strokes კონტურების მოხსნა + Place Brush Art ფუნჯის ნახატის განთავსება + Replace Brush Art ფუნჯის ნახატის ჩანაცვლება + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? კონტურები იყენებს ამ ფუნჯს. გამოყენებულ იქნას ახალი ნახატი ამ კონტურებზე, თუ დარჩეს ისინი როგორც არის და დაემატოს ფუნჯის ასლი ახალი ნახატით? + Apply to Strokes კონტურებზე გამოყენება + Leave Strokes კონტურების დატოვება +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ნახატი არ აქვს (ნახატი მხოლოდ გაფანტვის, მხატვრულ და პატერნის ფუნჯებს აქვს) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools განლაგების ხელსაწყოები Arrange in Grid… ბადეში განლაგება… @@ -6959,6 +7013,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 უსაფრთხო რეჟიმი @@ -7016,6 +7075,19 @@ font-script Japanese იაპონური @plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ +# Learn panel + Learn სწავლა + Explore tutorials გაკვეთილების დათვალიერება + For you თქვენთვის + Skills უნარები + Get started დაწყება + Quick skill guide უნარების სწრაფი გზამკვლევი + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. მოკლე პრაქტიკული გაკვეთილები ძირითადი ხელსაწყოებისთვის, დაჯგუფებული უნარების მიხედვით. აირჩიეთ უნარი მისი გაკვეთილების სანახავად. + List Tutorials გაკვეთილების სია + Show Tutorial გაკვეთილის ჩვენება +@msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) +@msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) + # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება Deferred Effects გადადებული ეფექტები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0603fa9933..31e606898a 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 엔벨로프 왜곡 @@ -520,6 +524,8 @@ digits Malayalam 말라얄람어 Extrude & Bevel… 돌출 및 경사… 3D (Classic) 3D(클래식) Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… + Revolve (Classic)… 회전체(클래식)… + Rotate (Classic)… 회전(클래식)… Surface 표면 Wireframe 와이어프레임 No Shading 셰이딩 없음 @@ -833,6 +839,14 @@ digits Malayalam 말라얄람어 Damp Paper 젖은 종이 Outlines and Rules 윤곽선과 괘선 Hand-Drawn 손그림 + Painted and Sketched 페인팅과 스케치 + Washed Blue 번진 파랑 + Bristle Ochre 붓결 황토 + Pastel Heather 파스텔 헤더 + Charcoal Shade 목탄 음영 + Ink Contour 잉크 윤곽 + Wet Ink 젖은 먹 + Fine Pen 가는 펜 Gradient Finishes 그레이디언트 마감 Shape Effects 모양 효과 Blends and Transparency 블렌드와 투명도 @@ -852,6 +866,8 @@ digits Malayalam 말라얄람어 Spectrum Gradients 스펙트럼 그레이디언트 Sky Gradients 하늘 그레이디언트 Neutral Gradients 중간색 그레이디언트 + Organic Fades 유기적 페이드 + Holographic 홀로그래픽 Duotone Gradients 듀오톤 그레이디언트 Radial Glows 방사형 광선 Save Swatch Library… 견본 라이브러리 저장… @@ -863,6 +879,7 @@ digits Malayalam 말라얄람어 Search Commands… 명령 검색… VectorCraft Help… VectorCraft 도움말… Preview 미리 보기 + Preview: 미리 보기: Show Edges 가장자리 표시 Show Corner Widget 모퉁이 위젯 표시 Show Template 템플릿 표시 @@ -1449,6 +1466,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 +2732,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 지원되지 않는 아트웍 래스터화 @@ -2908,6 +2935,9 @@ mark-style Japanese 일본식 Right Plane 오른쪽 평면 Right: 오른쪽: Roman Hanging Punctuation 로마자 걸림 문장 부호 + Korean Line Break Anywhere 한글 어디서나 줄 바꿈 + East Asian Composition Settings 동아시아 조판 설정 + Korean Composition Settings 한글 조판 설정 Rotate 회전 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 @@ -4311,6 +4341,7 @@ theme Appearance 외관 @msg `preserve` must be an object `preserve`는 개체여야 합니다 @msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다 @msg `stops` must be an array `stops`는 배열이어야 합니다 +@msg `{key}` must be an array `{key}`은(는) 배열이어야 합니다 @msg `{k}` must be [x, y] `{k}`은(는) [x, y]여야 합니다 @msg a gradient needs at least two stops 그레이디언트에는 정지점이 두 개 이상 필요합니다 @msg give both `start` and `end` [x, y] (or neither) `start`와 `end` [x, y]를 모두 지정하세요(또는 둘 다 생략) @@ -4599,6 +4630,7 @@ theme Appearance 외관 @msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다 @msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다 @msg not an HP-GL file HP-GL 파일이 아닙니다 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다 @msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다 @msg the EMF header is damaged EMF 헤더가 손상되었습니다 @msg damaged 손상됨 @@ -5593,6 +5625,7 @@ lighting Light 광원 Ruby 루비 Geometric Guides 기하학적 안내선 Geometric Patterns 기하학적 패턴 + Grit Textures 입자 텍스처 Ease In 서서히 시작 Ease Out 서서히 종료 Ease In Out 서서히 시작 및 종료 @@ -5653,6 +5686,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 +5750,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`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 @@ -5863,6 +5904,17 @@ axis Y Axis Y축 Charge Wave 차지 웨이브 Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 + Bristle Brushes 강모 브러시 모음 + Fine Liner 가는 라이너 + Pointed Flat 뾰족한 평붓 + Stubby Round 짧은 둥근 붓 + Dry Scrub 마른 문지르기 + Soft Dome 부드러운 돔 + Filbert 필버트 + Angled Round 사선 둥근 붓 + Angled Flat 사선 평붓 + Fan Blender 블렌딩 부채붓 + Grass Fan 잔디 부채붓 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 Save Brush Library… 브러시 라이브러리 저장… @@ -6075,6 +6127,10 @@ anchor Points: 포인트: @msg features lists at most {max} tags features에는 태그를 최대 {max}개까지 지정할 수 있습니다 @msg unknown OpenType feature `{t}` 알 수 없는 OpenType 기능 `{t}` @msg {label} must be a number above 0 and at most {max} {label}은(는) 0 초과 {max} 이하의 숫자여야 합니다 +@msg {label} must be at most {max} characters {label}은(는) 최대 {max}자여야 합니다 +@msg {label} must be one character {label}은(는) 한 글자여야 합니다 +@msg at most {max} tab stops 탭 정지는 최대 {max}개까지 지정할 수 있습니다 +@msg unknown align `{other}` 알 수 없는 정렬 `{other}` @msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다 # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6237,6 +6293,12 @@ breakText Glyphs 글리프 Delete the selected brushes? 선택한 브러시를 삭제하시겠습니까? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까? Remove Strokes 선 제거 + Place Brush Art 브러시 아트 배치 + Replace Brush Art 브러시 아트 바꾸기 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 선에서 이 브러시를 사용합니다. 새 아트를 해당 선에 적용하시겠습니까, 아니면 선을 그대로 두고 새 아트로 브러시 사본을 추가하시겠습니까? + Apply to Strokes 선에 적용 + Leave Strokes 선 유지 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}`에는 아트가 없습니다 (흩뿌리기, 아트, 패턴 브러시에만 아트가 있습니다) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 배치 도구 Arrange in Grid… 격자로 배치… @@ -6432,6 +6494,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 안전 모드 @@ -6484,6 +6551,19 @@ breakText Glyphs 글리프 @plural {n} line cut|{n} lines cut 줄 {n}개 잘림 @msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 +# Learn panel + Learn 학습 + Explore tutorials 튜토리얼 살펴보기 + For you 추천 + Skills 기술 + Get started 시작하기 + Quick skill guide 빠른 기술 가이드 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 기본 도구를 익히는 짧은 실습 튜토리얼을 기술별로 모았습니다. 기술을 선택하면 해당 튜토리얼이 표시됩니다. + List Tutorials 튜토리얼 목록 + Show Tutorial 튜토리얼 표시 +@msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) +@msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) + # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 Deferred Effects 보류된 효과 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c1559611fe..822506ca7d 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 @@ -464,6 +468,8 @@ Extrude & Bevel… Extruderen en afschuinen… 3D (Classic) 3D (klassiek) Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… + Revolve (Classic)… Draaien (klassiek)… + Rotate (Classic)… Roteren (klassiek)… Surface Oppervlak Wireframe Draadmodel No Shading Geen arcering @@ -534,6 +540,7 @@ Gaussian Blur… Gaussiaanse vervaging… View Beeld Preview Voorbeeld + Preview: Voorbeeld: Overprint Preview Overprintvoorbeeld Pixel Preview Pixelvoorbeeld Trim View Bijsnijweergave @@ -767,6 +774,14 @@ Damp Paper Vochtig papier Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend + Painted and Sketched Geschilderd en geschetst + Washed Blue Verwassen blauw + Bristle Ochre Borsteloker + Pastel Heather Pastelheide + Charcoal Shade Houtskoolschaduw + Ink Contour Inktcontour + Wet Ink Natte inkt + Fine Pen Fijne pen Gradient Finishes Verloopafwerkingen Shape Effects Vormeffecten Blends and Transparency Vermengingen en transparantie @@ -786,6 +801,8 @@ Spectrum Gradients Spectrumverlopen Sky Gradients Luchtverlopen Neutral Gradients Neutrale verlopen + Organic Fades Organische vervagingen + Holographic Holografisch Duotone Gradients Duotoonverlopen Radial Glows Radiale gloed Save Swatch Library… Staalenbibliotheek opslaan… @@ -1820,6 +1837,9 @@ digits Malayalam Malayalam Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links Roman Hanging Punctuation Romeinse hangende leestekens + Korean Line Break Anywhere Koreaans regeleinde overal + East Asian Composition Settings Instellingen voor Oost-Aziatische zetting + Korean Composition Settings Instellingen voor Koreaanse zetting Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. Single-line Composer Compositie per regel Toggle Line Composer Compositiemethode wisselen @@ -2206,6 +2226,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. @@ -4504,6 +4526,7 @@ imageTrace Outline Contour @msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0} @msg not an EMF or WMF file geen EMF- of WMF-bestand @msg not an HP-GL file geen HP-GL-bestand +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` moet een array van 1 tot 255 #rrggbb-kleuren zijn @msg not found niet gevonden @msg not in pattern editing mode niet in patroonbewerkingsmodus @msg finish editing the pattern first rond eerst het bewerken van het patroon af @@ -5902,6 +5925,7 @@ swatch Bright Violet Felviolet Ruby Ruby Geometric Guides Geometrische hulplijnen Geometric Patterns Geometrische patronen + Grit Textures Korrelige texturen Ease In Langzaam beginnen Ease Out Langzaam eindigen Ease In Out Langzaam beginnen en eindigen @@ -5955,6 +5979,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 @@ -6100,6 +6125,17 @@ axis Y Axis Y-as Charge Wave Laadgolf Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten + Bristle Brushes Borstelpenselen + Fine Liner Fijne liner + Pointed Flat Spitse platte + Stubby Round Korte ronde + Dry Scrub Droog schrobben + Soft Dome Zachte koepel + Filbert Kattentong + Angled Round Schuine ronde + Angled Flat Schuine platte + Fan Blender Vervloeiwaaier + Grass Fan Graswaaier Pattern Borders Patroonranden Scatter Shapes Strooivormen Save Brush Library… Penseelbibliotheek opslaan… @@ -6443,6 +6479,12 @@ breakText Glyphs Glyphs Delete the selected brushes? Geselecteerde kwasten verwijderen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen? Remove Strokes Contouren verwijderen + Place Brush Art Penseelillustratie plaatsen + Replace Brush Art Penseelillustratie vervangen + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Contouren gebruiken dit penseel. De nieuwe illustratie op deze contouren toepassen, of ze laten zoals ze zijn en een kopie van het penseel met de nieuwe illustratie toevoegen? + Apply to Strokes Op contouren toepassen + Leave Strokes Contouren behouden +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` heeft geen illustratie (alleen verspreidings-, art- en patroonpenselen hebben er een) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Schikgereedschappen Arrange in Grid… In raster schikken… @@ -6625,6 +6667,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 @@ -6672,6 +6719,19 @@ breakText Glyphs Glyphs @plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd @plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort +# Learn panel + Learn Leren + Explore tutorials Tutorials verkennen + For you Voor jou + Skills Vaardigheden + Get started Aan de slag + Quick skill guide Snelgids voor vaardigheden + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktische tutorials voor de belangrijkste gereedschappen, gegroepeerd per vaardigheid. Kies een vaardigheid om de tutorials ervan te zien. + List Tutorials Tutorials weergeven + Show Tutorial Tutorial tonen +@msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) +@msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) + # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen Deferred Effects Uitgestelde effecten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 267f6a4e35..b53f7969fc 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 @@ -451,6 +455,8 @@ Extrude & Bevel… Wyciągnięcie i fazowanie… 3D (Classic) 3D (klasyczne) Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… + Revolve (Classic)… Obrót (klasyczny)… + Rotate (Classic)… Obróć (klasyczny)… Surface Powierzchnia Wireframe Szkielet No Shading Bez cieniowania @@ -524,6 +530,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 @@ -763,6 +770,14 @@ Damp Paper Wilgotny papier Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie + Painted and Sketched Malowane i szkicowane + Washed Blue Rozmyty błękit + Bristle Ochre Ochra pędzlem + Pastel Heather Pastelowy wrzos + Charcoal Shade Cień węglem + Ink Contour Kontur tuszem + Wet Ink Mokry tusz + Fine Pen Cienkie pióro Gradient Finishes Wykończenia gradientowe Shape Effects Efekty kształtów Blends and Transparency Przejścia i przezroczystość @@ -782,6 +797,8 @@ Spectrum Gradients Gradienty widma Sky Gradients Gradienty nieba Neutral Gradients Gradienty neutralne + Organic Fades Organiczne zanikania + Holographic Holograficzne Duotone Gradients Gradienty dwutonowe Radial Glows Blaski promieniste Save Swatch Library… Zapisz bibliotekę próbek… @@ -1918,6 +1935,9 @@ digits Malayalam Malajalam Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej Roman Hanging Punctuation Wysunięta interpunkcja łacińska + Korean Line Break Anywhere Koreański podział wiersza w dowolnym miejscu + East Asian Composition Settings Ustawienia składu wschodnioazjatyckiego + Korean Composition Settings Ustawienia składu koreańskiego Select a text object to edit its paragraph attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty akapitu. Single-line Composer Kompozytor jednowierszowy Toggle Line Composer Przełącz kompozytor wierszy @@ -2304,6 +2324,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. @@ -4375,6 +4397,7 @@ imageTrace Outline Kontur @msg `globalAdjust` must be an object `globalAdjust` musi być obiektem @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5 @msg `stops` must be an array `stops` musi być tablicą +@msg `{key}` must be an array `{key}` musi być tablicą @msg `svg` must be an object of SVG options (see document.formats) `svg` musi być obiektem opcji SVG (patrz document.formats) @msg `{_1}` is no save command `{_1}` nie jest poleceniem zapisu @msg `{area}` is no recovery area name `{area}` nie jest nazwą obszaru odzyskiwania @@ -4692,6 +4715,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0} @msg not an EMF or WMF file to nie jest plik EMF ani WMF @msg not an HP-GL file to nie jest plik HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka @msg finish editing the pattern first najpierw zakończ edycję wzorka @@ -5871,6 +5895,7 @@ mark-style Japanese Japońskie Ruby Ruby Geometric Guides Geometryczne linie pomocnicze Geometric Patterns Wzorki geometryczne + Grit Textures Tekstury ziarniste Ease In Łagodny początek Ease Out Łagodny koniec Ease In Out Łagodny początek i koniec @@ -5929,6 +5954,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 +6018,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 @@ -6139,6 +6172,17 @@ axis Y Axis Oś Y Charge Wave Fala ładunku Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne + Bristle Brushes Pędzle włosiankowe + Fine Liner Cienka linia + Pointed Flat Szpiczasty płaski + Stubby Round Krótki okrągły + Dry Scrub Suche szorowanie + Soft Dome Miękka kopuła + Filbert Koci język + Angled Round Skośny okrągły + Angled Flat Skośny płaski + Fan Blender Wachlarz do mieszania + Grass Fan Wachlarz do trawy Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone Save Brush Library… Zapisz bibliotekę pędzli… @@ -6349,6 +6393,10 @@ anchor Points: Punkty: @msg features lists at most {max} tags features może zawierać najwyżej {max} znaczników @msg unknown OpenType feature `{t}` nieznana funkcja OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musi być liczbą większą od 0 i nie większą niż {max} +@msg {label} must be at most {max} characters {label} może mieć najwyżej {max} znaków +@msg {label} must be one character {label} musi być jednym znakiem +@msg at most {max} tab stops najwyżej {max} tabulatorów +@msg unknown align `{other}` nieznane wyrównanie `{other}` @msg no character style `{name}` Nie znaleziono stylu znakowego `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6511,6 +6559,12 @@ breakText Glyphs Glify Delete the selected brushes? Usunąć zaznaczone pędzle? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle? Remove Strokes Usuń obrysy + Place Brush Art Umieść kompozycję pędzla + Replace Brush Art Zastąp kompozycję pędzla + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Obrysy używają tego pędzla. Zastosować nową kompozycję do tych obrysów, czy pozostawić je bez zmian i dodać kopię pędzla z nową kompozycją? + Apply to Strokes Zastosuj do obrysów + Leave Strokes Pozostaw obrysy +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nie ma kompozycji (mają ją tylko pędzle rozproszone, artystyczne i wzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Narzędzia układania Arrange in Grid… Ułóż w siatce… @@ -6706,6 +6760,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 @@ -6758,6 +6817,19 @@ breakText Glyphs Glify @plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` +# Learn panel + Learn Nauka + Explore tutorials Przeglądaj samouczki + For you Dla Ciebie + Skills Umiejętności + Get started Pierwsze kroki + Quick skill guide Krótki przewodnik po umiejętnościach + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krótkie praktyczne samouczki dotyczące podstawowych narzędzi, pogrupowane według umiejętności. Wybierz umiejętność, aby zobaczyć jej samouczki. + List Tutorials Wyświetl samouczki + Show Tutorial Pokaż samouczek +@msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) +@msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) + # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty Deferred Effects Odroczone efekty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 96f759990f..9332eb3889 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1046,6 +1046,8 @@ antiAlias Sharp Nítido Extrude & Bevel… Extrusão e Bisel… 3D (Classic) 3D (Clássico) Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… + Revolve (Classic)… Revolver (Clássico)… + Rotate (Classic)… Girar (Clássico)… Surface Superfície Wireframe Aramado No Shading Sem Sombreamento @@ -1278,6 +1280,14 @@ antiAlias Sharp Nítido Hair Space Espaço Fino Hand Tool Ferramenta Mão Hand-Drawn Desenhado à Mão + Painted and Sketched Pintado e Esboçado + Washed Blue Azul Lavado + Bristle Ochre Ocre de Cerdas + Pastel Heather Urze Pastel + Charcoal Shade Sombra a Carvão + Ink Contour Contorno a Nanquim + Wet Ink Nanquim Úmido + Fine Pen Pena Fina Handles Alças Hard Light Luz Forte Hard Mix Mistura definida @@ -1953,6 +1963,8 @@ antiAlias Sharp Nítido Options… Opções… Order: Ordem: Ordinals Ordinais + Organic Fades Esmaecimentos Orgânicos + Holographic Holográficos Orientation Orientação Orientation: Orientação: Original Original @@ -2120,6 +2132,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 +2214,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 +2371,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 @@ -2428,6 +2447,9 @@ digits Malayalam Malaiala Rise… Rise… Roman Baseline Linha de Base Romana Roman Hanging Punctuation Pontuação Pendurada Romana + Korean Line Break Anywhere Quebra de Linha Coreana em Qualquer Lugar + East Asian Composition Settings Configurações de Composição do Leste Asiático + Korean Composition Settings Configurações de Composição Coreana Rotate Girar Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização @@ -3790,6 +3812,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5 @msg `stops` must be an array `stops` deve ser uma matriz +@msg `{key}` must be an array `{key}` deve ser uma matriz @msg `svg` must be an object of SVG options (see document.formats) `svg` deve ser um objeto com opções de SVG (veja document.formats) @msg `{_1}` is no save command `{_1}` não é um comando de salvar @msg `{area}` is no recovery area name `{area}` não é o nome de uma área de recuperação @@ -4097,6 +4120,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0} @msg not an EMF or WMF file não é um arquivo EMF ou WMF @msg not an HP-GL file não é um arquivo HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão @msg finish editing the pattern first termine de editar o padrão primeiro @@ -5932,6 +5956,7 @@ mark-style Japanese Japonesas Ruby Ruby Geometric Guides Guias geométricas Geometric Patterns Padrões geométricos + Grit Textures Texturas granuladas Ease In Entrada suave Ease Out Saída suave Ease In Out Entrada e saída suaves @@ -5990,6 +6015,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 +6079,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 @@ -6200,6 +6233,17 @@ axis Y Axis Eixo Y Charge Wave Onda de carga Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas + Bristle Brushes Pincéis de Cerdas + Fine Liner Traço Fino + Pointed Flat Chato Pontudo + Stubby Round Redondo Curto + Dry Scrub Esfregado Seco + Soft Dome Cúpula Suave + Filbert Língua de Gato + Angled Round Redondo Chanfrado + Angled Flat Chato Chanfrado + Fan Blender Leque Esfumador + Grass Fan Leque de Grama Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão Save Brush Library… Salvar Biblioteca de Pincéis… @@ -6410,6 +6454,10 @@ anchor Points: Pontos: @msg features lists at most {max} tags features aceita no máximo {max} marcas @msg unknown OpenType feature `{t}` recurso OpenType desconhecido `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve ser um número maior que 0 e no máximo {max} +@msg {label} must be at most {max} characters {label} deve ter no máximo {max} caracteres +@msg {label} must be one character {label} deve ser um único caractere +@msg at most {max} tab stops no máximo {max} paradas de tabulação +@msg unknown align `{other}` alinhamento desconhecido `{other}` @msg no character style `{name}` Estilo de caractere `{name}` não encontrado # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6573,6 +6621,12 @@ breakText Glyphs Glifos Delete the selected brushes? Excluir os pincéis selecionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis? Remove Strokes Remover Traçados + Place Brush Art Colocar Arte do Pincel + Replace Brush Art Substituir Arte do Pincel + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Traçados usam este pincel. Aplicar a nova arte a esses traçados, ou deixá-los como estão e adicionar uma cópia do pincel com a nova arte? + Apply to Strokes Aplicar aos Traçados + Leave Strokes Manter Traçados +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` não tem arte (só os pincéis de dispersão, de arte e de padrão têm) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ferramentas de Organização Arrange in Grid… Organizar em Grade… @@ -6923,6 +6977,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 @@ -6975,6 +7034,19 @@ swatch Yellow Green Verde-amarelado @plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` +# Learn panel + Learn Aprender + Explore tutorials Explorar tutoriais + For you Para você + Skills Habilidades + Get started Primeiros passos + Quick skill guide Guia rápido de habilidades + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriais práticos curtos das ferramentas essenciais, agrupados por habilidade. Escolha uma habilidade para ver os tutoriais dela. + List Tutorials Listar Tutoriais + Show Tutorial Mostrar Tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) + # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados Deferred Effects Efeitos Adiados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 05cee41b14..b1885cf95b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1151,6 +1151,8 @@ font-technology Variable Вариативные Extrude & Bevel… Выдавливание и фаска… 3D (Classic) 3D (классические) Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… + Revolve (Classic)… Вращение (классическое)… + Rotate (Classic)… Поворот (классический)… Surface Поверхность Wireframe Каркас No Shading Без затенения @@ -1463,6 +1465,14 @@ file-filter Templates Шаблоны Hair Space Волосяная шпация Hand Tool Инструмент «Рука» Hand-Drawn Нарисованный от руки + Painted and Sketched Живопись и эскизы + Washed Blue Размытый синий + Bristle Ochre Охра кистью + Pastel Heather Пастельный вереск + Charcoal Shade Тень углём + Ink Contour Контур тушью + Wet Ink Влажная тушь + Fine Pen Тонкое перо Handles Маркеры Hard Light Жёсткий свет Hard Mix Жёсткое наложение @@ -2381,6 +2391,8 @@ file-filter Templates Шаблоны Options for Selection… Параметры выделения… Options: Параметры: Options… Параметры… + Organic Fades Органические затухания + Holographic Голографические or more spaces with a tab или больше пробелов на табуляцию Order: Порядок: Ordinals Порядковые числительные @@ -2574,6 +2586,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 +2681,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растровые эффекты Raster File Format: Формат растрового файла: + Include Placed Images Включать размещенные изображения + Include Fill Hatches Включать штриховку заливок Raster Sample Size: Размер растрового образца: Raster/Vector Balance: Баланс растровых/векторных: Rasterize Растеризовать @@ -2849,6 +2864,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 Заменить шрифтом @@ -2928,6 +2947,9 @@ digits Malayalam Малаялам Rise… Подъём… Roman Baseline Римская базовая линия Roman Hanging Punctuation Римская висячая пунктуация + Korean Line Break Anywhere Перенос корейского текста в любом месте + East Asian Composition Settings Параметры восточноазиатского набора + Korean Composition Settings Параметры корейского набора Rotate Повернуть Rotate 90° CW Повернуть на 90° по часовой стрелке Rotate Tool Инструмент «Поворот» @@ -4280,6 +4302,7 @@ axis Both Оба @msg `globalAdjust` must be an object `globalAdjust` должно быть объектом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5 @msg `stops` must be an array `stops` должно быть массивом +@msg `{key}` must be an array `{key}` должно быть массивом @msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats) @msg `{_1}` is no save command `{_1}` не является командой сохранения @msg `{area}` is no recovery area name `{area}` не является именем области восстановления @@ -4596,6 +4619,7 @@ axis Both Оба @msg not a valid plug-in module: {0} не является допустимым модулем: {0} @msg not an EMF or WMF file не является файлом EMF или WMF @msg not an HP-GL file не является файлом HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора @msg finish editing the pattern first сначала завершите редактирование узора @@ -5626,6 +5650,7 @@ lighting Light Свет Ruby Руби Geometric Guides Геометрические направляющие Geometric Patterns Геометрические узоры + Grit Textures Зернистые текстуры Ease In Плавное начало Ease Out Плавное окончание Ease In Out Плавное начало и окончание @@ -5686,6 +5711,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 +5775,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`: ничего не установлено @@ -5896,6 +5929,17 @@ axis Y Axis Ось Y Charge Wave Волна заряда Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья + Bristle Brushes Щетинные кисти + Fine Liner Тонкий лайнер + Pointed Flat Заострённая плоская + Stubby Round Короткая круглая + Dry Scrub Сухое растирание + Soft Dome Мягкий купол + Filbert Кошачий язык + Angled Round Скошенная круглая + Angled Flat Скошенная плоская + Fan Blender Веер для растушёвки + Grass Fan Веер для травы Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры Save Brush Library… Сохранить библиотеку кистей… @@ -6471,6 +6515,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features содержит не более {max} тегов @msg unknown OpenType feature `{t}` неизвестная функция OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} должно быть числом больше 0 и не более {max} +@msg {label} must be at most {max} characters {label} должно содержать не более {max} символов +@msg {label} must be one character {label} должно быть одним символом +@msg at most {max} tab stops не более {max} позиций табуляции +@msg unknown align `{other}` неизвестное выравнивание `{other}` @msg no character style `{name}` Стиль символов `{name}` не найден # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6632,6 +6680,12 @@ breakText Glyphs Глифы Delete the selected brushes? Удалить выбранные кисти? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти? Remove Strokes Убрать обводки + Place Brush Art Поместить рисунок кисти + Replace Brush Art Заменить рисунок кисти + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Обводки используют эту кисть. Применить новый рисунок к этим обводкам или оставить их как есть и добавить копию кисти с новым рисунком? + Apply to Strokes Применить к обводкам + Leave Strokes Оставить обводки +@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` нет рисунка (он есть только у кистей-распылений, художественных и узорных кистей) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменты размещения Arrange in Grid… Разместить по сетке… @@ -6939,6 +6993,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 Безопасный режим @@ -6991,6 +7050,19 @@ shape Open Открытый @plural {n} line cut|{n} lines cut {n} строка обрезана|{n} строки обрезаны|{n} строк обрезано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last` +# Learn panel + Learn Обучение + Explore tutorials Обзор уроков + For you Для вас + Skills Навыки + Get started Начало работы + Quick skill guide Краткое руководство по навыкам + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткие практические уроки по основным инструментам, сгруппированные по навыкам. Выберите навык, чтобы увидеть его уроки. + List Tutorials Перечислить уроки + Show Tutorial Показать урок +@msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) +@msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты Deferred Effects Отложенные эффекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 88c01f2350..a34fdbe0d5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1132,6 +1132,8 @@ font-technology Variable Variabla Extrude & Bevel… Extrudera och fasa… 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… + Revolve (Classic)… Rotera runt axel (klassisk)… + Rotate (Classic)… Rotera (klassisk)… Surface Yta Wireframe Trådram No Shading Ingen skuggning @@ -1412,6 +1414,14 @@ font-technology Variable Variabla Hair Space Hårfint blanksteg Hand Tool Handverktyg Hand-Drawn Handritad + Painted and Sketched Målat och skissat + Washed Blue Urtvättad blå + Bristle Ochre Borstockra + Pastel Heather Pastelljung + Charcoal Shade Kolskugga + Ink Contour Tuschkontur + Wet Ink Våt tusch + Fine Pen Fin penna Handles Handtag Hard Light Hårt ljus Hard Mix Hård blandning @@ -2295,6 +2305,8 @@ font-technology Variable Variabla Options for Selection… Alternativ för markering… Options: Alternativ: Options… Alternativ… + Organic Fades Organiska toningar + Holographic Holografiska or more spaces with a tab eller fler blanksteg med en tabb Order: Ordning: Ordinals Ordningstal @@ -2484,6 +2496,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 +2589,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 +2771,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 @@ -2834,6 +2853,9 @@ digits Malayalam Malayalam Rise… Stigning… Roman Baseline Baslinje (roman) Roman Hanging Punctuation Hängande interpunktion (roman) + Korean Line Break Anywhere Koreansk radbrytning var som helst + East Asian Composition Settings Inställningar för östasiatisk sättning + Korean Composition Settings Inställningar för koreansk sättning Rotate Rotera Rotate 90° CW Rotera 90° medurs Rotate Tool Rotationsverktyg @@ -4153,6 +4175,7 @@ axis Both Båda @msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5 @msg `stops` must be an array `stops` måste vara en array +@msg `{key}` must be an array `{key}` måste vara en array @msg `svg` must be an object of SVG options (see document.formats) `svg` måste vara ett objekt med SVG-alternativ (se document.formats) @msg `{_1}` is no save command `{_1}` är inget sparkommando @msg `{area}` is no recovery area name `{area}` är inget namn på ett återställningsområde @@ -4462,6 +4485,7 @@ axis Both Båda @msg not a valid plug-in module: {0} inte ett giltigt plugin: {0} @msg not an EMF or WMF file inte en EMF- eller WMF-fil @msg not an HP-GL file inte en HP-GL-fil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering @msg finish editing the pattern first slutför redigeringen av mönstret först @@ -6039,6 +6063,7 @@ font-script Japanese Japanska Ruby Ruby Geometric Guides Geometriska stödlinjer Geometric Patterns Geometriska mönster + Grit Textures Korniga texturer Ease In Långsam start Ease Out Långsamt slut Ease In Out Långsam start och slut @@ -6097,6 +6122,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 +6182,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 @@ -6303,6 +6335,17 @@ axis Y Axis Y-axel Charge Wave Laddningsvåg Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar + Bristle Brushes Borstpenslar + Fine Liner Fin linje + Pointed Flat Spetsig platt + Stubby Round Kort rund + Dry Scrub Torr skrubbning + Soft Dome Mjuk kupol + Filbert Oval platt + Angled Round Sned rund + Angled Flat Sned platt + Fan Blender Blandande solfjäder + Grass Fan Grässolfjäder Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer Save Brush Library… Spara penselbibliotek… @@ -6541,6 +6584,9 @@ shape Open Öppen @msg features lists at most {max} tags features får innehålla högst {max} taggar @msg unknown OpenType feature `{t}` okänd OpenType-funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} måste vara ett tal över 0 och högst {max} +@msg {label} must be at most {max} characters {label} får innehålla högst {max} tecken +@msg {label} must be one character {label} måste vara ett tecken +@msg at most {max} tab stops högst {max} tabbstopp @msg no character style `{name}` Teckenformatet `{name}` hittades inte # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6702,6 +6748,12 @@ breakText Glyphs Glyfer Delete the selected brushes? Radera de valda penslarna? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna? Remove Strokes Ta bort konturer + Place Brush Art Placera penselgrafik + Replace Brush Art Ersätt penselgrafik + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturer använder den här penseln. Använd den nya grafiken på konturerna, eller låt dem vara som de är och lägg till en kopia av penseln med den nya grafiken? + Apply to Strokes Använd på konturer + Leave Strokes Behåll konturer +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (bara spridnings-, konst- och mönsterpenslar har grafik) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ordningsverktyg Arrange in Grid… Ordna i rutnät… @@ -7462,7 +7514,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 +8382,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 @@ -8382,6 +8439,19 @@ digits Hindi Hindi @plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade @msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` +# Learn panel + Learn Lär dig + Explore tutorials Utforska självstudier + For you För dig + Skills Färdigheter + Get started Kom igång + Quick skill guide Snabbguide till färdigheter + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korta praktiska självstudier för de viktigaste verktygen, ordnade efter färdighet. Välj en färdighet för att se dess självstudier. + List Tutorials Lista självstudier + Show Tutorial Visa självstudie +@msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) +@msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) + # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter Deferred Effects Uppskjutna effekter diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0612d7ca7d..ade1efa8be 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -238,6 +238,41 @@ fn anti_aliasing_method_labels_are_in_complete_catalogs() { } } +/// The Painted and Sketched graphic style library's name and its styles' names have a row in every +/// bundled catalog. +#[test] +fn painted_and_sketched_style_names_are_in_every_catalog() { + let lib = vectorcraft_doc::style_libs::builtin_style_library("painted-sketched").expect("built in"); + let labels: Vec<&str> = std::iter::once(lib.name.as_str()).chain(lib.styles.iter().map(|g| g.name.as_str())).collect(); + assert_eq!(labels.len(), 8); + for lang in LANGUAGES.iter().filter(|lang| !lang.source.is_empty()) { + for label in &labels { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + +/// 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 +2041,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 ae5fd67167..5014aa7b39 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1124,6 +1124,8 @@ font-technology Variable Değişken Extrude & Bevel… Ekstrüzyon ve Eğim… 3D (Classic) 3D (Klasik) Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… + Revolve (Classic)… Döndür (Klasik)… + Rotate (Classic)… Çevir (Klasik)… Surface Yüzey Wireframe Tel Kafes No Shading Gölgeleme Yok @@ -1376,6 +1378,14 @@ font-technology Variable Değişken Hair Space Saç Alanı Hand Tool El Aracı Hand-Drawn elle çizilmiş + Painted and Sketched Boyanmış ve Eskiz + Washed Blue Soluk Mavi + Bristle Ochre Fırça Darbeli Toprak Sarısı + Pastel Heather Pastel Funda + Charcoal Shade Kömür Gölge + Ink Contour Mürekkep Kontur + Wet Ink Islak Mürekkep + Fine Pen İnce Kalem Handles Kollar Hard Light Sert Işık Harmony Rules: Uyum Kuralları: @@ -2167,6 +2177,8 @@ shape Open Açık Options for Selection… Seçim Seçenekleri… Options: Seçenekler: Options… Seçenekler… + Organic Fades Organik Solmalar + Holographic Holografik or more spaces with a tab veya sekmeli daha fazla alan Order: Sipariş: Ordinals Sıra Sayıları @@ -2357,6 +2369,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 +2461,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 +2640,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 @@ -2701,6 +2720,9 @@ digits Malayalam Malayalam Rise… Yüksel… Roman Baseline Roma Taban Çizgisi Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Korean Line Break Anywhere Korece Satırı Her Yerde Kes + East Asian Composition Settings Doğu Asya Dizgi Ayarları + Korean Composition Settings Korece Dizgi Ayarları Rotate Döndür Rotate 90° CW 90° CW döndür Rotate Tool Döndürme Aracı @@ -4033,6 +4055,7 @@ axis Both Her İkisi @msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır @msg `stops` must be an array `stops` bir dizi olmalıdır +@msg `{key}` must be an array `{key}` bir dizi olmalıdır @msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) @msg `{_1}` is no save command `{_1}` kaydetme komutu değil @msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir @@ -4317,6 +4340,7 @@ axis Both Her İkisi @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @msg not an EMF or WMF file EMF veya WMF dosyası değil @msg not an HP-GL file HP-GL dosyası değil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil @@ -6012,6 +6036,7 @@ axis Y Axis Y ekseni Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar Geometric Patterns Geometrik Desenler + Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? @@ -6031,6 +6056,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 +6120,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 @@ -6241,6 +6274,17 @@ axis Y Axis Y ekseni Charge Wave Şarj Dalgası Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları + Bristle Brushes Kıl Fırçalar + Fine Liner İnce Çizgi + Pointed Flat Sivri Düz + Stubby Round Kısa Yuvarlak + Dry Scrub Kuru Ovma + Soft Dome Yumuşak Kubbe + Filbert Kedi Dili + Angled Round Eğik Yuvarlak + Angled Flat Eğik Düz + Fan Blender Harmanlama Yelpazesi + Grass Fan Çim Yelpazesi Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri Save Brush Library… Fırça Kitaplığını Kaydet… @@ -6450,6 +6494,10 @@ anchor Points: Noktalar: @msg features lists at most {max} tags features en fazla {max} etiket içerebilir @msg unknown OpenType feature `{t}` bilinmeyen OpenType özelliği `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0'ın üzerinde ve en fazla {max} olan bir sayı olmalıdır +@msg {label} must be at most {max} characters {label} en fazla {max} karakter olmalıdır +@msg {label} must be one character {label} tek bir karakter olmalıdır +@msg at most {max} tab stops en fazla {max} sekme durağı +@msg unknown align `{other}` bilinmeyen hizalama `{other}` @msg no character style `{name}` `{name}` karakter stili bulunamadı # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6611,6 +6659,12 @@ breakText Glyphs Glifler Delete the selected brushes? Seçili fırçalar silinsin mi? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi? Remove Strokes Konturları Kaldır + Place Brush Art Fırça Çizimini Yerleştir + Replace Brush Art Fırça Çizimini Değiştir + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturlar bu fırçayı kullanıyor. Yeni çizim bu konturlara uygulansın mı, yoksa konturlar olduğu gibi bırakılıp fırçanın yeni çizimli bir kopyası mı eklensin? + Apply to Strokes Konturlara Uygula + Leave Strokes Konturları Bırak +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` fırçasının çizimi yok (yalnızca serpme, resim ve desen fırçalarının çizimi vardır) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Düzenleme Araçları Arrange in Grid… Izgarada Düzenle… @@ -6806,6 +6860,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 @@ -6858,6 +6917,19 @@ breakText Glyphs Glifler @plural {n} line cut|{n} lines cut {n} satır kesildi @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli +# Learn panel + Learn Öğren + Explore tutorials Öğreticileri Keşfet + For you Sizin için + Skills Beceriler + Get started Başlarken + Quick skill guide Hızlı beceri rehberi + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Temel araçlar için becerilere göre gruplanmış kısa uygulamalı öğreticiler. Öğreticilerini görmek için bir beceri seçin. + List Tutorials Öğreticileri Listele + Show Tutorial Öğreticiyi Göster +@msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) +@msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile Deferred Effects Ertelenen Efektler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5561149bf8..4947a1a266 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 Скасувати накладення білого під час виведення @@ -1148,6 +1156,8 @@ font-technology Variable Варіативні Extrude & Bevel… Видавлювання та фаска… 3D (Classic) 3D (класичні) Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… + Revolve (Classic)… Обертання (класичне)… + Rotate (Classic)… Поворот (класичний)… Surface Поверхня Wireframe Каркас No Shading Без затінення @@ -1460,6 +1470,14 @@ file-filter Templates Шаблони Hair Space Волосяна шпація Hand Tool Інструмент «Рука» Hand-Drawn Намальований від руки + Painted and Sketched Мальовані та ескізні + Washed Blue Розмитий синій + Bristle Ochre Охра пензлем + Pastel Heather Пастельний верес + Charcoal Shade Тінь вугіллям + Ink Contour Контур тушшю + Wet Ink Мокра туш + Fine Pen Тонке перо Handles Маркери Hard Light Жорстке світло Hard Mix Жорстке накладання @@ -2378,6 +2396,8 @@ file-filter Templates Шаблони Options for Selection… Параметри виділення… Options: Параметри: Options… Параметри… + Organic Fades Органічні згасання + Holographic Голографічні or more spaces with a tab або більше прогалин на табуляцію Order: Порядок: Ordinals Порядкові числівники @@ -2571,6 +2591,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 +2686,8 @@ digits Malayalam Малаялам Range: Діапазон: Raster Effects Растрові ефекти Raster File Format: Формат растрового файлу: + Include Placed Images Включати розміщені зображення + Include Fill Hatches Включати штрихування заливок Raster Sample Size: Розмір растрового зразка: Raster/Vector Balance: Баланс растрових/векторних: Rasterize Растеризувати @@ -2846,6 +2869,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 Замінити шрифтом @@ -2925,6 +2952,9 @@ digits Malayalam Малаялам Rise… Підйом… Roman Baseline Римська базова лінія Roman Hanging Punctuation Римська висяча пунктуація + Korean Line Break Anywhere Перенесення корейського тексту будь-де + East Asian Composition Settings Параметри східноазійського набору + Korean Composition Settings Параметри корейського набору Rotate Повернути Rotate 90° CW Повернути на 90° за годинниковою стрілкою Rotate Tool Інструмент «Поворот» @@ -4270,6 +4300,7 @@ axis Both Обидві @msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5 @msg `stops` must be an array `stops` має бути масивом +@msg `{key}` must be an array `{key}` має бути масивом @msg `svg` must be an object of SVG options (see document.formats) `svg` має бути об’єктом параметрів SVG (див. document.formats) @msg `{_1}` is no save command `{_1}` не є командою збереження @msg `{area}` is no recovery area name `{area}` не є ім’ям області відновлення @@ -4586,6 +4617,7 @@ axis Both Обидві @msg not a valid plug-in module: {0} не є допустимим модулем: {0} @msg not an EMF or WMF file не є файлом EMF або WMF @msg not an HP-GL file не є файлом HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка @msg finish editing the pattern first спочатку завершіть редагування візерунка @@ -5612,6 +5644,7 @@ lighting Light Світло Ruby Рубі Geometric Guides Геометричні напрямні Geometric Patterns Геометричні візерунки + Grit Textures Зернисті текстури Ease In Плавний початок Ease Out Плавне завершення Ease In Out Плавний початок і завершення @@ -5672,6 +5705,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 +5769,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`: нічого не встановлено @@ -5882,6 +5923,17 @@ axis Y Axis Вісь Y Charge Wave Хвиля заряду Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера + Bristle Brushes Щетинні пензлі + Fine Liner Тонкий лайнер + Pointed Flat Загострений плаский + Stubby Round Короткий круглий + Dry Scrub Сухе розтирання + Soft Dome М'який купол + Filbert Котячий язик + Angled Round Скошений круглий + Angled Flat Скошений плаский + Fan Blender Віяло для розтушовування + Grass Fan Віяло для трави Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури Save Brush Library… Зберегти бібліотеку пензлів… @@ -6094,6 +6146,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features містить не більше {max} тегів @msg unknown OpenType feature `{t}` невідома функція OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} має бути числом більше 0 і не більше {max} +@msg {label} must be at most {max} characters {label} має містити не більше {max} символів +@msg {label} must be one character {label} має бути одним символом +@msg at most {max} tab stops не більше {max} позицій табуляції +@msg unknown align `{other}` невідоме вирівнювання `{other}` @msg no character style `{name}` Стиль символів `{name}` не знайдено # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6256,6 +6312,12 @@ breakText Glyphs Гліфи Delete the selected brushes? Видалити вибрані пензлі? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі? Remove Strokes Забрати обведення + Place Brush Art Розмістити малюнок пензля + Replace Brush Art Замінити малюнок пензля + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Обведення використовують цей пензель. Застосувати новий малюнок до цих обведень чи залишити їх як є і додати копію пензля з новим малюнком? + Apply to Strokes Застосувати до обведень + Leave Strokes Залишити обведення +@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` немає малюнка (він є лише в пензлів-розпорошень, художніх і візерункових пензлів) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Інструменти розміщення Arrange in Grid… Розмістити сіткою… @@ -6451,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 Безпечний режим @@ -6503,6 +6570,19 @@ breakText Glyphs Гліфи @plural {n} line cut|{n} lines cut {n} рядок обрізано|{n} рядки обрізано|{n} рядків обрізано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last` +# Learn panel + Learn Навчання + Explore tutorials Переглянути уроки + For you Для вас + Skills Навички + Get started Початок роботи + Quick skill guide Короткий посібник із навичок + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткі практичні уроки з основних інструментів, згруповані за навичками. Виберіть навичку, щоб побачити її уроки. + List Tutorials Перелічити уроки + Show Tutorial Показати урок +@msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) +@msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти Deferred Effects Відкладені ефекти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a3b98d9bde..1a0f194a86 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 @@ -1321,6 +1329,8 @@ font-technology Variable Biến thiên Extrude & Bevel… Kéo dài & vát… 3D (Classic) 3D (cổ điển) Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… + Revolve (Classic)… Xoay tròn (cổ điển)… + Rotate (Classic)… Xoay (cổ điển)… Surface Bề mặt Wireframe Khung dây No Shading Không tô bóng @@ -1687,6 +1697,14 @@ file-filter Templates Mẫu Halo Vầng hào quang Hand Tool Công cụ Bàn tay Hand-Drawn Vẽ tay + Painted and Sketched Vẽ màu và phác thảo + Washed Blue Xanh nhạt nhòa + Bristle Ochre Vàng đất cọ khô + Pastel Heather Tím phấn màu + Charcoal Shade Bóng than + Ink Contour Đường viền mực + Wet Ink Mực ướt + Fine Pen Bút mảnh Handles Tay nắm Hard Cứng Hard Light Ánh sáng mạnh @@ -2880,6 +2898,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 +2973,8 @@ 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 + Holographic Ảnh ba chiều Radial Gradient Chuyển sắc hướng tâm Conic Gradient Chuyển sắc hình nón Diamond Gradient Chuyển sắc hình thoi @@ -2971,6 +2992,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 +3151,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ữ @@ -3229,6 +3256,9 @@ mark-style Japanese Kiểu Nhật Rise… Nâng lên… Roman Baseline Đường cơ sở Roman Roman Hanging Punctuation Dấu câu treo Roman + Korean Line Break Anywhere Ngắt dòng tiếng Hàn ở bất kỳ đâu + East Asian Composition Settings Cài đặt dàn trang Đông Á + Korean Composition Settings Cài đặt dàn trang tiếng Hàn Rotate Xoay Rotate 90° CW Xoay 90° theo chiều kim đồng hồ Rotate Tool Công cụ Xoay @@ -4404,6 +4434,7 @@ theme Appearance Giao diện @msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng @msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5 @msg `stops` must be an array `stops` phải là một mảng +@msg `{key}` must be an array `{key}` phải là một mảng @msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats) @msg `to` must be "corner" or "smooth" `to` phải là "corner" hoặc "smooth" @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` phải là "emBox", "capHeight", "xHeight" hoặc "icfBox" @@ -4899,6 +4930,7 @@ theme Appearance Giao diện @msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được. @msg not an EMF or WMF file không phải tệp EMF hoặc WMF @msg not an HP-GL file không phải tệp HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết @msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước @@ -5613,6 +5645,7 @@ theme Appearance Giao diện Ruby Ruby Geometric Guides Đường dẫn hình học Geometric Patterns Họa tiết hình học + Grit Textures Kết cấu hạt Ease In Chậm lúc đầu Ease Out Chậm lúc cuối Ease In Out Chậm lúc đầu và cuối @@ -5673,6 +5706,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 +5770,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 @@ -5883,6 +5924,17 @@ axis Y Axis Trục Y Charge Wave Sóng nạp Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp + Bristle Brushes Bộ cọ lông + Fine Liner Nét mảnh + Pointed Flat Cọ dẹt nhọn + Stubby Round Cọ tròn ngắn + Dry Scrub Chà khô + Soft Dome Vòm mềm + Filbert Cọ lưỡi mèo + Angled Round Cọ tròn xiên + Angled Flat Cọ dẹt xiên + Fan Blender Cọ quạt hòa trộn + Grass Fan Cọ quạt vẽ cỏ Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán Save Brush Library… Lưu thư viện cọ… @@ -6095,6 +6147,10 @@ anchor Points: Điểm: @msg features lists at most {max} tags features chứa tối đa {max} thẻ @msg unknown OpenType feature `{t}` tính năng OpenType không xác định `{t}` @msg {label} must be a number above 0 and at most {max} {label} phải là một số lớn hơn 0 và tối đa {max} +@msg {label} must be at most {max} characters {label} chỉ được có tối đa {max} ký tự +@msg {label} must be one character {label} phải là một ký tự +@msg at most {max} tab stops tối đa {max} điểm dừng tab +@msg unknown align `{other}` căn chỉnh không xác định `{other}` @msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6257,6 +6313,12 @@ breakText Glyphs Glyph Delete the selected brushes? Xóa các cọ đã chọn? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ? Remove Strokes Gỡ nét + Place Brush Art Đặt đối tượng của cọ + Replace Brush Art Thay đối tượng của cọ + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Có nét đang dùng cọ này. Áp dụng đối tượng mới cho các nét đó, hay giữ nguyên chúng và thêm một bản sao của cọ với đối tượng mới? + Apply to Strokes Áp dụng cho nét + Leave Strokes Giữ nguyên nét +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` không có đối tượng (chỉ cọ phân tán, cọ bản vẽ và cọ họa tiết mới có) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Công cụ sắp xếp Arrange in Grid… Sắp xếp theo lưới… @@ -6452,6 +6514,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 @@ -6504,6 +6571,19 @@ breakText Glyphs Glyph @plural {n} line cut|{n} lines cut đã cắt {n} dòng @msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` +# Learn panel + Learn Học + Explore tutorials Khám phá hướng dẫn + For you Dành cho bạn + Skills Kỹ năng + Get started Bắt đầu + Quick skill guide Hướng dẫn nhanh về kỹ năng + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Các hướng dẫn thực hành ngắn cho những công cụ thiết yếu, xếp theo kỹ năng. Chọn một kỹ năng để xem các hướng dẫn của nó. + List Tutorials Liệt kê hướng dẫn + Show Tutorial Hiện hướng dẫn +@msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) +@msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) + # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn Deferred Effects Hiệu ứng bị hoãn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 08a49d902b..288cf3fb2c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1094,6 +1094,8 @@ antiAlias Sharp 锐利 Extrude & Bevel… 突出与斜角… 3D (Classic) 3D (经典) Extrude & Bevel (Classic)… 突出与斜角 (经典)… + Revolve (Classic)… 回转 (经典)… + Rotate (Classic)… 旋转 (经典)… Surface 表面 Wireframe 线框 No Shading 无底纹 @@ -1403,6 +1405,14 @@ file-filter Templates 模板 Hair Space 极细空格 Hand Tool 抓手工具 Hand-Drawn 手绘 + Painted and Sketched 绘画与素描 + Washed Blue 水洗蓝 + Bristle Ochre 干刷土黄 + Pastel Heather 粉彩淡紫 + Charcoal Shade 炭笔阴影 + Ink Contour 墨线轮廓 + Wet Ink 湿墨 + Fine Pen 细笔 Handles 手柄 Hard Light 强光 Hard Mix 实色混合 @@ -2253,6 +2263,8 @@ file-filter Templates 模板 Options 选项 Options: 选项: Options… 选项… + Organic Fades 自然渐隐 + Holographic 全息 or more spaces with a tab 或多个空格加一个制表符 Order: 顺序: Ordinals 序数字 @@ -2445,6 +2457,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 +2554,8 @@ digits Malayalam 马拉雅拉姆数字 Range: 范围: Raster Effects 栅格效果 Raster File Format: 栅格文件格式: + Include Placed Images 包含置入的图像 + Include Fill Hatches 包含填色的图案填充 Raster Sample Size: 栅格取样大小: Raster/Vector Balance: 栅格/矢量平衡: Rasterize 栅格化 @@ -2720,6 +2735,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 替换为字体 @@ -2797,6 +2816,9 @@ digits Malayalam 马拉雅拉姆数字 Rise… 上升… Roman Baseline 罗马基线 Roman Hanging Punctuation 西文悬挂标点 + Korean Line Break Anywhere 韩文任意位置换行 + East Asian Composition Settings 东亚排版设置 + Korean Composition Settings 韩文排版设置 Rotate 旋转 Rotate 90° CW 顺时针旋转 90° Rotate Tool 旋转工具 @@ -4100,6 +4122,7 @@ imageTrace Outline 轮廓 @msg `preserve` must be an object `preserve` 必须是对象 @msg `globalAdjust` must be an object `globalAdjust` 必须是对象 @msg `stops` must be an array `stops` 必须是数组 +@msg `{key}` must be an array `{key}` 必须是数组 @msg `{k}` must be [x, y] `{k}` 必须是 [x, y] @msg a gradient needs at least two stops 渐变至少需要两个色标 @msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出) @@ -4378,6 +4401,7 @@ imageTrace Outline 轮廓 @msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 @msg not an EMF or WMF file 不是 EMF 或 WMF 文件 @msg not an HP-GL file 不是 HP-GL 文件 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组 @msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 @msg the EMF header is damaged EMF 文件头已损坏 @msg damaged 已损坏 @@ -5854,6 +5878,7 @@ lighting Light 光源 Ruby 注音 Geometric Guides 几何参考线 Geometric Patterns 几何图案 + Grit Textures 颗粒纹理 Ease In 缓入 Ease Out 缓出 Ease In Out 缓入缓出 @@ -5913,6 +5938,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 +6002,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` 给出的不符:未安装任何内容 @@ -6123,6 +6156,17 @@ axis Y Axis Y 轴 Charge Wave 充能波 Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 + Bristle Brushes 鬃毛画笔集 + Fine Liner 细勾线笔 + Pointed Flat 尖头平笔 + Stubby Round 短圆笔 + Dry Scrub 干擦 + Soft Dome 柔和圆顶 + Filbert 榛形笔 + Angled Round 斜圆笔 + Angled Flat 斜平笔 + Fan Blender 混色扇形笔 + Grass Fan 画草扇形笔 Pattern Borders 图案边框 Scatter Shapes 散布形状 Save Brush Library… 保存画笔库… @@ -6335,6 +6379,10 @@ anchor Points: 点: @msg features lists at most {max} tags features 最多包含 {max} 个标签 @msg unknown OpenType feature `{t}` 未知的 OpenType 特性 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必须是大于 0 且不超过 {max} 的数字 +@msg {label} must be at most {max} characters {label} 最多 {max} 个字符 +@msg {label} must be one character {label} 必须是一个字符 +@msg at most {max} tab stops 最多 {max} 个制表位 +@msg unknown align `{other}` 未知的对齐方式 `{other}` @msg no character style `{name}` 找不到字符样式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6496,6 +6544,12 @@ breakText Glyphs 字形 Delete the selected brushes? 删除选中的画笔吗? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗? Remove Strokes 移除描边 + Place Brush Art 置入画笔图稿 + Replace Brush Art 替换画笔图稿 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 有描边使用了此画笔。要将新图稿应用到这些描边,还是保留描边不变并添加一个使用新图稿的画笔副本? + Apply to Strokes 应用于描边 + Leave Strokes 保留描边 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 没有图稿 (只有散布画笔、艺术画笔和图案画笔有图稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 Arrange in Grid… 按网格排列… @@ -6691,6 +6745,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 安全模式 @@ -6740,6 +6799,19 @@ breakText Glyphs 字形 @plural {n} line cut|{n} lines cut 已截断 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 +# Learn panel + Learn 学习 + Explore tutorials 浏览教程 + For you 为你推荐 + Skills 技能 + Get started 入门 + Quick skill guide 快速技能指南 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 针对基本工具的简短实操教程,按技能分组。选择一项技能即可查看其教程。 + List Tutorials 列出教程 + Show Tutorial 显示教程 +@msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) +@msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) + # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 Deferred Effects 延迟的效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fec11986ef..cc0cc92195 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 輸出時捨棄白色疊印 @@ -1118,6 +1126,8 @@ font-technology Variable 可變 Extrude & Bevel… 突出與斜角… 3D (Classic) 3D (經典) Extrude & Bevel (Classic)… 突出與斜角 (經典)… + Revolve (Classic)… 迴轉 (經典)… + Rotate (Classic)… 旋轉 (經典)… Surface 表面 Wireframe 線框 No Shading 無網底 @@ -1431,6 +1441,14 @@ file-filter Templates 範本 Hair Space 極細空格 Hand Tool 手形工具 Hand-Drawn 手繪 + Painted and Sketched 繪畫與素描 + Washed Blue 水洗藍 + Bristle Ochre 乾刷土黃 + Pastel Heather 粉彩淡紫 + Charcoal Shade 炭筆陰影 + Ink Contour 墨線輪廓 + Wet Ink 濕墨 + Fine Pen 細筆 Handles 控制把手 Hard Light 實光 Hard Mix 實色混合 @@ -1894,6 +1912,7 @@ file-filter Templates 範本 @msg `preserve` must be an object `preserve` 必須是物件 @msg `globalAdjust` must be an object `globalAdjust` 必須是物件 @msg `stops` must be an array `stops` 必須是陣列 +@msg `{key}` must be an array `{key}` 必須是陣列 @msg `{k}` must be [x, y] `{k}` 必須是 [x, y] @msg a gradient needs at least two stops 漸層至少需要兩個色標 @msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供) @@ -2176,6 +2195,7 @@ file-filter Templates 範本 @msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖 @msg not an EMF or WMF file 不是 EMF 或 WMF 檔案 @msg not an HP-GL file 不是 HP-GL 檔案 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列 @msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取 @msg the EMF header is damaged EMF 檔頭已損毀 @msg damaged 已損毀 @@ -3345,6 +3365,8 @@ file-filter Templates 範本 Options for Selection… 選取範圍的選項… Options: 選項: Options… 選項… + Organic Fades 自然漸隱 + Holographic 全像 or more spaces with a tab 個以上的空格取代為定位字元 Order: 順序: Ordinals 序數字 @@ -3539,6 +3561,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 +3659,8 @@ digits Malayalam 馬來亞拉姆數字 Range: 範圍: Raster Effects 點陣效果 Raster File Format: 點陣檔案格式: + Include Placed Images 包含置入的影像 + Include Fill Hatches 包含填色的圖案填充 Raster Sample Size: 點陣取樣大小: Raster/Vector Balance: 點陣/向量平衡: Rasterize 點陣化 @@ -3817,6 +3842,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 取代為字體 @@ -3896,6 +3925,9 @@ digits Malayalam 馬來亞拉姆數字 Rise… 上升… Roman Baseline 羅馬基線 Roman Hanging Punctuation 羅馬懸掛標點 + Korean Line Break Anywhere 韓文任意位置換行 + East Asian Composition Settings 東亞排版設定 + Korean Composition Settings 韓文排版設定 Rotate 旋轉 Rotate 90° CW 順時針旋轉 90° Rotate Tool 旋轉工具 @@ -5650,6 +5682,7 @@ lighting Light 光源 Ruby 注音 Geometric Guides 幾何參考線 Geometric Patterns 幾何圖樣 + Grit Textures 顆粒紋理 Ease In 緩入 Ease Out 緩出 Ease In Out 緩入緩出 @@ -5710,6 +5743,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 +5807,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` 提供的不符:未安裝任何內容 @@ -5920,6 +5961,17 @@ axis Y Axis Y 軸 Charge Wave 充能波 Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 + Bristle Brushes 鬃毛筆刷集 + Fine Liner 細勾線筆 + Pointed Flat 尖頭平筆 + Stubby Round 短圓筆 + Dry Scrub 乾擦 + Soft Dome 柔和圓頂 + Filbert 榛形筆 + Angled Round 斜圓筆 + Angled Flat 斜平筆 + Fan Blender 混色扇形筆 + Grass Fan 畫草扇形筆 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 Save Brush Library… 儲存筆刷資料庫… @@ -6132,6 +6184,10 @@ anchor Points: 點: @msg features lists at most {max} tags features 最多包含 {max} 個標籤 @msg unknown OpenType feature `{t}` 未知的 OpenType 功能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必須是大於 0 且不超過 {max} 的數字 +@msg {label} must be at most {max} characters {label} 最多 {max} 個字元 +@msg {label} must be one character {label} 必須是一個字元 +@msg at most {max} tab stops 最多 {max} 個定位點 +@msg unknown align `{other}` 未知的對齊方式 `{other}` @msg no character style `{name}` 找不到字元樣式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). @@ -6294,6 +6350,12 @@ breakText Glyphs 字符 Delete the selected brushes? 要刪除選取的筆刷嗎? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎? Remove Strokes 移除筆畫 + Place Brush Art 置入筆刷圖稿 + Replace Brush Art 取代筆刷圖稿 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 有筆畫使用了此筆刷。要將新圖稿套用到這些筆畫,還是保留筆畫不變並加入一個使用新圖稿的筆刷複本? + Apply to Strokes 套用至筆畫 + Leave Strokes 保留筆畫 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 沒有圖稿 (只有散佈筆刷、藝術筆刷和圖樣筆刷有圖稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 Arrange in Grid… 依格狀排列… @@ -6489,6 +6551,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 安全模式 @@ -6538,6 +6605,19 @@ breakText Glyphs 字符 @plural {n} line cut|{n} lines cut 已截斷 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 +# Learn panel + Learn 學習 + Explore tutorials 瀏覽教學課程 + For you 為你推薦 + Skills 技能 + Get started 入門 + Quick skill guide 快速技能指南 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 針對基本工具的簡短實作教學課程,依技能分組。選擇一項技能即可查看其教學課程。 + List Tutorials 列出教學課程 + Show Tutorial 顯示教學課程 +@msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) +@msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) + # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 Deferred Effects 延後的效果 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 8de3f5e579..f521e45c22 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -197,6 +197,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")), ("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")), ("frame", include_bytes!("../../../assets/icons/frame.svg")), + ("graduation-cap", include_bytes!("../../../assets/icons/graduation-cap.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("group", include_bytes!("../../../assets/icons/group.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 93abdfd31c..dff4fa0dd2 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -30,7 +30,7 @@ pub use open::{ #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; -pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; +pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to}; #[cfg(test)] pub(crate) use save::{report_write_error, saved_status}; @@ -90,7 +90,7 @@ mod tests { use vectorcraft_testkit::ai; let (mut app, _) = app(); let damaged = b"%AI12_CompressedData not zlib"; - let notes = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + let notes = notes_of; let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f"); let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); let [page, layers] = ¬es(&r)[..] else { panic!("two notes: {r}") }; @@ -125,6 +125,36 @@ mod tests { assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown); } + /// A save and an export with several notes: the status bar names the first, counts them, and + /// keeps every one of them, in the reply's order, for its hover. A save without notes keeps + /// none. + #[test] + fn a_save_and_an_export_keep_every_note_for_the_hover() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let r = app.run("file.save", json!({"path": "/out/a.svg", "compress": true})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(notes[0].starts_with("SVG keeps the artwork but not everything"), "{r}"); + assert_eq!(app.ui.status, saved_status("/out/a.svg", &r)); + assert!(app.ui.status.contains("with 2 notes"), "{}", app.ui.status); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + + app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap(); + assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None)); + + // A PDF export of a knockout group and of type in a font that isn't installed. + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("transparency.set", json!({"knockout": "on"})).unwrap(); + let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), ¬es[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 562dc0e00a..03e0196267 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,7 @@ use super::save::{ - ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path, - target_path_keeping, view_file, write_encoded, write_named, write_reporting, + ask_before_losing, ask_to_replace_files, checks_replaced, export_path, is_web, notes_of, remember_active_artboard, replaced_files, report_saved, + report_write_error, saved_status, show_with_notes, target_path, target_path_keeping, view_file, with_path, write_encoded, write_named, + write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -44,6 +45,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option std::sync::Arc::new(d), }; let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); + let uid = st.uid; let path = export_path(app, path, f)?; let mut again = params.clone(); if let Some(o) = again.as_object_mut() { @@ -68,11 +70,18 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 { @@ -90,15 +99,21 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed_before, "file.exportAs", &rerun)); + } let what = match r["files"].as_array() { Some(files) => format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), }; - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - app.status(match warnings.first() { + let warnings = notes_of(&r); + let status = match warnings.first() { Some(first) => exported_with_notes(&what, warnings.len(), first), None => format!("Exported {what}"), - }); + }; + show_with_notes(app, status, warnings); Ok(r) })?; if r.get("path").is_none() { @@ -115,7 +130,7 @@ pub(crate) fn exported_with_notes(what: &str, n: usize, first: &str) -> String { /// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path /// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' -/// Export…), reported with the first of the command's warnings ([`report_saved`]). +/// Export…), reported with the command's warnings ([`report_saved`]). pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { let (path, v) = run_to_file(app, id, ext, params)?; report_saved(app, &path, &v); @@ -241,8 +256,9 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), /// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; /// without one the web downloads them (the ZIP, or each file) and the other frontends get them /// back as base64. With `openLocation`, the file manager shows the first file written. When an -/// export into a folder or a download has warnings, the status bar shows the first as -/// [`report_saved`] shows a save's notes: "Saved with n notes: …". +/// export into a folder or a download has warnings, the status bar shows them as [`report_saved`] +/// shows a save's notes: "Saved with n notes: …", and hovering the +/// message lists up to 30 of them, fewer when the window is small, with how many were left out. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { export_files(app, "document.exportForScreens", params) } @@ -312,7 +328,7 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati { status = format!("{status} ({e})"); } - app.status(status); + show_with_notes(app, status, notes_of(&r)); } else if let Some(download) = app.services.download.as_mut() { // A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone. let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect(); diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 3ca34cdd77..ae52b599b7 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,7 +1,7 @@ use vectorcraft_engine::cmd::plugin::InstallApproval; use vectorcraft_plugins::permissions; -use super::save::suggested; +use super::save::{notes_of, show_with_notes, suggested}; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; /// Template extensions New from Template's open dialog lists first. @@ -144,8 +144,8 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { app.sync_views(); let notes = open_notes(app, r); if let Some(first) = notes.first() { - app.status(opened_with_notes(notes.len(), first)); - app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let status = opened_with_notes(notes.len(), first); + show_with_notes(app, status, notes); } crate::dialogs::missing_links::after_open(app, r); } @@ -153,7 +153,7 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { /// The notes of the open `r`, those of what reading the file left out (its document's /// `import_losses`) first, each group in the order the import gave them. fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec { - let notes: Vec = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let notes = notes_of(r); let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()); let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default(); let (mut first, rest): (Vec, Vec) = notes.into_iter().partition(|n| losses.contains(n)); @@ -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/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..d9d475e76f 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -264,7 +264,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, (save_command(mode), with_path(p, &path))); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -291,7 +291,42 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, (save_command(mode), q)) +} + +/// `p` (an object, else none) with `path`. +pub(super) fn with_path(p: &Value, path: &str) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["path"] = json!(path); + p +} + +/// The files among `paths` that a write to `path` replaces although the save panel asked only +/// about `path`: every other one that `exists` finds, but those in `known`. None without `exists`. +pub(super) fn replaced_files<'a>( + exists: Option<&crate::ExistsFn>, + path: &str, + known: &[String], + paths: impl IntoIterator, +) -> Vec { + let Some(exists) = exists else { return Vec::new() }; + paths.into_iter().filter(|p| *p != path && !known.iter().any(|k| k == p) && exists(p)).map(str::to_string).collect() +} + +/// The `exists` service with which a save or export checks the files it writes beside the one +/// picked ([`replaced_files`]), none on the web (downloads), and the files it replaces without +/// asking: those the OK of the question about them allowed while it runs that again. +pub(super) fn checks_replaced(app: &VectorcraftApp) -> (Option, Vec) { + (app.services.exists.clone().filter(|_| !is_web(app)), app.picks.replace_files_ok.clone()) +} + +/// Ask before a save or export of the document `doc` (its uid) replaces `files`, files that are +/// there beside the one picked in the save panel (a file per artboard, a linked image), which the +/// panel did not ask about. OK runs `command` with `params` again, replacing them and `allowed` +/// (those an earlier OK allowed); Cancel writes nothing → `{pending}`. +pub(super) fn ask_to_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: &Value) -> Value { + dialogs::confirm::ask_replace_files(app, doc, files, allowed, command, params.clone()); + json!({ "pending": dialogs::confirm::REPLACE_FILES }) } /// The UI command that runs a save in `mode`. @@ -394,20 +429,35 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { } } -/// "Saved ", with the first of the result's warnings. +/// "Saved ", with the first of the result's warnings and how many there are; hovering the +/// message lists up to 30 of them, fewer when the window is small, with how many were left out +/// ([`show_with_notes`]). pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { - app.status(saved_status(path, r)); + show_with_notes(app, saved_status(path, r), notes_of(r)); +} + +/// The warnings of a reply `r`, in the reply's order (a save's notes start with what the format +/// loses). +pub(crate) fn notes_of(r: &Value) -> Vec { + r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() +} + +/// Show `status`, which names the first of `notes`, in the status bar and keep `notes` for its +/// hover ([`crate::state::UiState::status_notes`]). Without notes nothing is kept. +pub(crate) fn show_with_notes(app: &mut VectorcraftApp, status: String, notes: Vec) { + app.status(status); + app.ui.status_notes = (!notes.is_empty()).then(|| (app.ui.status.clone(), notes)); } /// [`report_saved`]'s message. pub(crate) fn saved_status(path: &str, r: &Value) -> String { - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let warnings = notes_of(r); match warnings.first() { Some(first) => crate::i18n::msg_n( warnings.len() as u64, "Saved {path} with {n} note: {first}", "Saved {path} with {n} notes: {first}", - &[("path", path), ("first", first)], + &[("path", path), ("first", first.as_str())], ), None => format!("Saved {path}"), } @@ -418,7 +468,10 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { +/// A file it writes beside the one at its path that is there already, and that the document's last +/// save did not write, is asked about before anything is written; `again` (a command and its params) +/// runs the save again once the question's OK allows it ([`ask_to_replace_files`]). +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Value)) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -446,10 +499,19 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let done = job.clone(); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let (exists, allowed) = checks_replaced(app); + let known: Vec = allowed.iter().chain(app.picks.written_beside.get(&uid).into_iter().flatten()).cloned().collect(); let work = move |write: Writer| { match stream { Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + None => job.write_unless( + |paths| { + let path = job.plan().path.as_deref().unwrap_or_default(); + let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied()); + (!replaced.is_empty()).then(|| json!({ "replaces": replaced })) + }, + |p, b| write(p, b).map_err(EngineError::Other), + ), } .map_err(|e| e.to_string()) }; @@ -457,11 +519,20 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let then = move |app: &mut VectorcraftApp, r: Result| { // Not written: the document keeps its file, format and modified state, and the user is told. let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + // Nothing written either: asked first. + if let Some(files) = r.get("replaces").and_then(Value::as_array) { + let files: Vec = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed, again.0, &again.1)); + } // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); if retargets { note_recent(app, &path); + // The files written beside the document's file, which its next save replaces. + let beside = ["files", "linked"].iter().flat_map(|k| r[*k].as_array().into_iter().flatten()).filter_map(Value::as_str); + let beside: Vec = beside.filter(|p| *p != path).map(str::to_string).collect(); + app.picks.written_beside.insert(uid, beside); } report_saved(app, &path, &r); Ok(r) diff --git a/crates/ui-egui/src/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..de3261de1b 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}))); @@ -81,6 +114,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Option io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), - "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), - "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), - "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), + // A confined request sees the document's own file, and the folder a save suggests, only + // inside its read roots. + "file.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => { + let mode = match id { + "file.save" => SaveMode::Save, + "file.saveAs" => SaveMode::SaveAs, + "file.saveCopy" => SaveMode::Copy, + _ => SaveMode::Template, + }; + io::save(app, mode, p, true).map(|mut r| { + vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str)); + r + }) + } "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), - "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), + "file.revert" if opt_bool(p, "confirmed") != Ok(Some(true)) => opt_bool(p, "confirmed").and_then(|_| io::ask_revert(app)), "file.reveal" => io::reveal(app), "file.cancelExport" => Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), @@ -386,9 +430,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 +463,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 +724,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 +733,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 +748,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 +774,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 +980,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 +1014,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/state.rs b/crates/ui-egui/src/menus/state.rs index ae7b7bb891..d68608824b 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -93,6 +93,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "window.taskBar" => app.ui.task_bar, "window.pathfinder.compoundShapes" => app.ui.shape_modes_compound, "window.taskBar.pin" => app.ui.task_bar_place.pinned, + "window.learn" => crate::panels::learn::visible(&app.ui), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3d1577e1be..e49f4788ea 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -524,6 +524,38 @@ fn classic_extrude_bevel_is_in_the_3d_classic_submenu() { assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap()); } +/// Effect › 3D and Materials › 3D (Classic) › Revolve (Classic)… opens its effect dialog, after +/// Extrude & Bevel (Classic)…, and nowhere else. +#[test] +fn classic_revolve_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicRevolve")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Revolve (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Extrude & Bevel (Classic)…").unwrap() < at("Revolve (Classic)…").unwrap()); +} + +/// Effect › 3D and Materials › 3D (Classic) › Rotate (Classic)… opens its effect dialog, after +/// Revolve (Classic)…, and nowhere else. +#[test] +fn classic_rotate_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicRotate")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Rotate (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Revolve (Classic)…").unwrap() < at("Rotate (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 916b8b311a..fb12428354 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -667,6 +667,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Image Trace", "imageTrace"), panel("Info", "info"), panel("Layers", "layers"), + cp("Learn", "window.learn", json!({})), panel("Libraries", "libraries"), panel("Links", crate::panels::links::ID), panel("Magic Wand", "magicWand"), @@ -721,6 +722,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..f435bfff22 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -635,6 +635,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", ), + ( + "window.learn", + "Learn", + "", + "{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows", + ), + ( + "learn.list", + "List Tutorials", + "", + "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + ), + ( + "learn.show", + "Show Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", @@ -749,6 +767,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", @@ -1487,7 +1511,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}", ), ( "file.print", @@ -1524,7 +1548,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.rs b/crates/ui-egui/src/panels.rs index 1106406bc0..899f026398 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,6 +26,7 @@ pub mod history; pub mod image_trace; pub mod info; pub mod layers; +pub mod learn; pub mod libraries; pub mod library_panel; pub mod links; @@ -185,6 +186,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { css_properties::ID => css_properties::show(app, ui), three_d::ID => three_d::show(app, ui), script_console::ID => script_console::show(app, ui), + learn::ID => learn::show(app, ui), crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), diff --git a/crates/ui-egui/src/panels/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/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index a282ff9973..8f29c70475 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -2,7 +2,9 @@ //! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking //! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or //! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with, -//! for Delete Brush and Duplicate Brush. Brush libraries open in library panels +//! for Delete Brush and Duplicate Brush. A scatter, art or pattern brush dragged onto an empty part +//! of the canvas places a copy of its art there, and art from the canvas Alt-dragged (Option-dragged +//! on macOS) onto such a brush replaces its art. Brush libraries open in library panels //! ([`BrushLibraries`]). use std::cell::RefCell; @@ -269,6 +271,22 @@ fn reorder_drop( } } +/// Art dragged off the canvas and held over the tile or row `resp` of a scatter, art or pattern +/// brush `name` (of type `ty`) with Alt (Option on macOS) held: the tile is outlined, and released +/// there it sets `replaced` to the brush and the art's ids, whose art then replaces the brush's. +/// Without Alt, or on another type of brush, the drop is left to the list, which opens New Brush. +fn replace_drop(ui: &Ui, resp: &egui::Response, name: &str, ty: &str, replaced: &mut Option<(String, Vec)>) { + if !matches!(ty, "scatter" | "art" | "pattern") || !ui.input(|i| i.modifiers.alt) { + return; + } + let Some(drag) = resp.dnd_hover_payload::() else { return }; + let PanelDrag::Art(ids) = &*drag else { return }; + ui.painter().rect_stroke(resp.rect, 0.0, egui::Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + if resp.dnd_release_payload::().is_some() { + *replaced = Some((name.to_string(), ids.iter().map(|id| id.0).collect())); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (list, current) = brushes(app); @@ -284,6 +302,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut clicked: Option<(String, egui::Modifiers)> = None; let mut options: Option = None; let mut moved: Option = None; + let mut replaced: Option<(String, Vec)> = None; let list_rect = widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); @@ -346,6 +365,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(ui.available_width(), 26.0), true); reorder_drop(ui, &resp, &list, (name, ty, i), true, &mut moved); + replace_drop(ui, &resp, name, ty, &mut replaced); pick(resp, name); } } else { @@ -353,6 +373,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(64.0, 28.0), false); reorder_drop(ui, &resp, &list, (name, ty, i), false, &mut moved); + replace_drop(ui, &resp, name, ty, &mut replaced); pick(resp, name); } }); @@ -361,6 +382,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.min_rect() }); + // Art Alt-dragged onto a scatter, art or pattern brush replaces its art, asking first when + // strokes use the brush. + if let Some((name, ids)) = replaced { + crate::dialogs::brush_replace_art::replace(app, &name, &ids); + } // Art dragged off the canvas and dropped on the list opens New Brush for that art, as the New // Brush button opens it for the selection; a brush library's brush dropped there is added. let zone = ui.interact(list_rect, ui.scope_id().with("brushes-drop"), Sense::hover()); diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index e4c5b9d764..fcab4373ce 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -461,6 +461,54 @@ fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() { assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}"); } +/// Art dragged off the canvas and released on a scatter, art or pattern brush with Alt held +/// replaces that brush's art (no stroke uses it, so nothing asks); without Alt, or on a +/// calligraphic brush, the list opens New Brush as before. +#[test] +fn art_alt_dropped_on_a_brush_with_art_replaces_its_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 12})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); + let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); + assert_eq!(shown(&brushes(&mut app).0, &[])[3], "Dots"); + let dots = app.run("brush.get", json!({"name": "Dots"})).unwrap(); + let drop = |app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64| { + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); + frame_with(&ctx, app, vec![egui::Event::PointerMoved(at)], modifiers, time, show); + frame_with(&ctx, app, vec![button(at, false)], modifiers, time + 0.1, show); + }; + // Without Alt: New Brush, and Dots stays as it was. + drop(&mut app, tiles[3].center(), egui::Modifiers::NONE, 1.0); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::new_brush::KIND)); + app.ui.dialog = None; + // Alt on a calligraphic or a bristle brush: New Brush too. + let list = brushes(&mut app).0; + let names = shown(&list, &[]); + let at = |name: &str| tiles[names.iter().position(|n| *n == name).unwrap()].center(); + for (i, name) in ["3 pt. Round", "Bristle Round"].into_iter().enumerate() { + let lib = app.run("brush.list", json!({})).unwrap(); + drop(&mut app, at(name), egui::Modifiers::ALT, 2.0 + i as f64); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::new_brush::KIND), "{name}"); + app.ui.dialog = None; + assert_eq!(app.run("brush.list", json!({})).unwrap(), lib, "{name}"); + } + assert_eq!(app.run("brush.get", json!({"name": "Dots"})).unwrap(), dots); + // Alt on Dots (scatter), Arrow (art) and Chain (pattern, its side tile): the rectangle is its + // art now, in one undo step each. + for (i, (name, key)) in [("Dots", "art"), ("Arrow", "art"), ("Chain", "side")].into_iter().enumerate() { + let steps = app.session.active().unwrap().history.undo.len(); + drop(&mut app, at(name), egui::Modifiers::ALT, 4.0 + i as f64); + assert!(app.ui.dialog.is_none(), "no stroke uses {name}: nothing asks"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "{name}"); + let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()[key].clone()).unwrap(); + let b = art.geometric_bounds().unwrap(); + assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 12.0).abs() < 1e-6, "{name}: {b:?}"); + } +} + /// A brush dragged onto the left half of another brush of its type goes before it, with a bar /// marking the slot while it is held there, as one undo step; dropped on a brush of another /// type, it stays where it is and the status bar says why. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 5f2f96a8cc..2f834a272c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -266,8 +266,9 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits /// it is the pair kerning after the character before the caret, as Alt+←/→ steps it -/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like -/// the others: the selected range, or all the text. +/// (`type.step`), or at the start of a line the space before the character after it, where Auto +/// and Optical set no space (#5111); nowhere to set at an empty line's start. Else it is a +/// character attribute like the others: the selected range, or all the text. fn set_kerning(app: &mut VectorcraftApp, value: Value) { let Some((id, a, b)) = text_editing(app) else { format(app, json!({"kerning": value})); @@ -278,15 +279,18 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) { return; } let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return }; - let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a); - if start >= a { - return; - } + let Some((target, before)) = vectorcraft_text::edit::caret_kerning_target(&t.plain_text(), a) else { return }; end_typing(app); - app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); + let params = if before { + json!({"id": id.0, "start": target.start, "end": target.end, "kerningBefore": value.as_f64().unwrap_or(0.0)}) + } else { + json!({"id": id.0, "start": target.start, "end": target.end, "kerning": value}) + }; + app.run("text.setRangeStyle", params).ok(); } -/// The pair before the caret, in 1/1000 em, using the layout the canvas draws. +/// The kerning at the caret, in 1/1000 em, using the layout the canvas draws: the pair before it, +/// or at the start of a line the space before the character after it. fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { let (id, a, b) = text_editing(app)?; if a != b { @@ -294,11 +298,12 @@ fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { } let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; let text = t.plain_text(); - let start = vectorcraft_text::edit::prev_char(&text, a); - if start >= a { - return None; - } + let (target, before) = vectorcraft_text::edit::caret_kerning_target(&text, a)?; + let start = target.start; let style = vectorcraft_text::edit::style_at(&t.runs, start + 1); + if before { + return Some((style.kerning_before, false)); + } if let Some(v) = style.kerning { return Some((v, false)); } diff --git a/crates/ui-egui/src/panels/css_properties.rs b/crates/ui-egui/src/panels/css_properties.rs index bc362fb576..eae559dff5 100644 --- a/crates/ui-egui/src/panels/css_properties.rs +++ b/crates/ui-egui/src/panels/css_properties.rs @@ -86,7 +86,7 @@ fn shown(app: &mut VectorcraftApp, ui: &Ui, opts: &CssOptions, all: bool) -> Opt let (id, scope) = if all { ("css.generate", "all") } else { ("css.selection", "selection") }; let out = app.session.execute(id, ¶ms(opts, scope)).unwrap_or_default(); let rules: Vec<&str> = out["rules"].as_array().into_iter().flatten().filter_map(|r| r["css"].as_str()).collect(); - let warnings = out["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let warnings = io::notes_of(&out); let job = (!rules.is_empty()) .then(|| Arc::new(highlight(rules.get(..MAX_SHOWN).unwrap_or(&rules), &t, egui::TextStyle::Monospace.resolve(ui.style())))); let skipped = out["skipped"].as_u64().unwrap_or(0); @@ -204,13 +204,14 @@ pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + // The status bar shows the warnings as Export As shows them. + let warnings = io::notes_of(&r); let what = &path; - app.status(match warnings.first() { + let status = match warnings.first() { Some(first) => io::exported_with_notes(what, warnings.len(), first), None => format!("Exported {path}"), - }); + }; + io::show_with_notes(app, status, warnings); Ok(json!({ "path": path, "rules": r["rules"], "images": images, "warnings": r["warnings"] })) } diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs new file mode 100644 index 0000000000..e49832c0da --- /dev/null +++ b/crates/ui-egui/src/panels/learn.rs @@ -0,0 +1,214 @@ +//! Learn panel (Window › Learn, and Explore tutorials at the right end of the app bar): short +//! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started +//! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each +//! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a +//! tutorial previews its numbered steps. Back goes up one level. `learn.list` lists the tutorials, +//! `learn.show` opens one and `window.learn` shows or hides the panel. + +pub mod catalog; + +use egui::{Sense, Stroke, Ui, vec2}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +pub const ID: &str = "learn"; +/// The height of a skill or tutorial row. +const ROW_HEIGHT: f32 = 28.0; + +/// The panel's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + ForYou, + Skills, +} + +/// What the panel shows: its tab and, on the Skills tab, the skill and the tutorial open. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct View { + pub tab: Tab, + pub skill: Option<&'static str>, + pub tutorial: Option<&'static str>, +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.horizontal(|ui| { + for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { + if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() { + app.ui.learn.tab = tab; + } + } + }); + crate::widgets::divider(ui); + match app.ui.learn.tab { + Tab::ForYou => for_you(app, ui), + Tab::Skills => skills(app, ui), + } +} + +/// The For you tab: Get started, with the Quick skill guide card. +fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::subheader(ui, tl!("Get started")); + ui.add_space(4.0); + let card = egui::Frame::NONE + .fill(t.panel_darker) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(egui::CornerRadius::same(t.radius)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + crate::icons::icon(ui, "graduation-cap", 18.0, t.text_strong); + ui.label(egui::RichText::new(tl!("Quick skill guide")).size(13.0).color(t.text_strong)); + }); + ui.add_space(4.0); + ui.add(egui::Label::new( + egui::RichText::new(tl!("Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials.")) + .size(11.5) + .color(t.text_dim), + )); + }); + let resp = ui.interact(card.response.rect, ui.scope_id().with("quick-skill-guide"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Quick skill guide"))); + if resp.clicked() { + app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + } +} + +/// The Skills tab: the skills, a skill's tutorials, or a tutorial's steps. +fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { + let open = app.ui.learn.tutorial.and_then(catalog::tutorial); + if let Some((skill, tutorial)) = open { + if back(ui) { + app.ui.learn.skill = Some(skill.id); + app.ui.learn.tutorial = None; + } + steps(ui, tutorial); + return; + } + if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { + if back(ui) { + app.ui.learn.skill = None; + } + crate::widgets::subheader(ui, skill.title); + for tutorial in skill.tutorials { + if row(ui, tutorial.title, None).clicked() { + app.ui.learn.tutorial = Some(tutorial.id); + } + } + return; + } + for skill in catalog::SKILLS { + if row(ui, skill.title, Some(skill.tutorials.len())).clicked() { + app.ui.learn.skill = Some(skill.id); + } + } +} + +/// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked. +fn back(ui: &mut Ui) -> bool { + crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() +} + +/// A tutorial's title and its numbered steps. +fn steps(ui: &mut Ui, tutorial: &catalog::Tutorial) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); + ui.add_space(4.0); + for (i, step) in tutorial.steps.iter().enumerate() { + ui.horizontal_top(|ui| { + ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + }); + ui.add_space(2.0); + } +} + +/// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the +/// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. +fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + let mut left = rect.left() + 6.0; + let mut right = rect.right() - 6.0; + let chevron = egui::Rect::from_center_size(egui::pos2(right - 6.0, rect.center().y), vec2(12.0, 12.0)); + crate::icons::paint(ui, "chevron-right", chevron, t.text_dim); + right = chevron.left() - 6.0; + if let Some(total) = tutorials { + ui.painter().circle_stroke(egui::pos2(left + 8.0, rect.center().y), 7.0, Stroke::new(2.0, t.border)); + left += 24.0; + let count = ui.painter().layout_no_wrap(format!("0/{total}"), egui::FontId::proportional(11.5), t.text_dim); + right -= count.size().x; + ui.painter().galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim); + right -= 6.0; + } + let name = ui.painter().layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY); + let clip = egui::Rect::from_min_max(egui::pos2(left, rect.top()), egui::pos2(right.max(left), rect.bottom())); + ui.painter().with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// The Learn panel's UI commands: `window.learn`, `learn.list` and `learn.show`. +pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + Some(match id { + "window.learn" => toggle(app, p), + "learn.list" => Ok(list()), + "learn.show" => open(app, p), + _ => return None, + }) +} + +/// Whether the panel can be seen: it is the front panel, the dock is not hidden and no full screen +/// mode hides the panels. +pub(crate) fn visible(ui: &crate::state::UiState) -> bool { + ui.dock && ui.screen_mode < 3 && crate::panel_docking::showing(ui, ID) +} + +/// `window.learn {show?}`: show the panel (true), hide it (false) or toggle it (omitted), as the +/// app bar's Explore tutorials button and Window › Learn do → whether it shows ([`visible`]). +/// Showing it also shows a hidden dock; in a full screen mode it makes the panel the front one. +fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result { + let shown = visible(&app.ui); + let show = crate::menus::opt_bool(p, "show")?.unwrap_or(!shown); + if show && !shown { + crate::panel_docking::reveal_panel(app, ID)?; + } else if !show && shown { + app.run("window.panel", json!({ "panel": ID, "toggle": true }))?; + } + Ok(json!(visible(&app.ui))) +} + +/// `learn.list`: every skill with its tutorials and their step counts. +fn list() -> Value { + let skills = catalog::SKILLS.iter().map(|s| { + let tutorials: Vec = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect(); + json!({"id": s.id, "title": s.title, "tutorials": tutorials}) + }); + json!({ "skills": skills.collect::>() }) +} + +/// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the +/// tutorial. A missing or unknown id is an error and changes nothing. +fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "tutorial"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + let steps: Vec<&str> = tutorial.steps.iter().map(|s| s.text).collect(); + Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs new file mode 100644 index 0000000000..b1ffd897d7 --- /dev/null +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -0,0 +1,185 @@ +//! The Learn panel's tutorials, VectorCraft's own short lessons, grouped by skill. The text is +//! English data: the panel shows it as written in every interface language. + +/// One step of a tutorial: what to do, and the tool it chooses or the command it runs, if any. +pub struct Step { + pub text: &'static str, + /// A tool id ([`vectorcraft_tools::catalog::tool_info`]) the step names. + pub tool: Option<&'static str>, + /// A command id the step names. + pub command: Option<&'static str>, +} + +/// A tutorial: an id for `learn.show`, its title and its numbered steps. +pub struct Tutorial { + pub id: &'static str, + pub title: &'static str, + pub steps: &'static [Step], +} + +/// A skill and its tutorials, in the order the panel lists them. +pub struct Skill { + pub id: &'static str, + pub title: &'static str, + pub tutorials: &'static [Tutorial], +} + +/// A step that names no tool or command. +const fn say(text: &'static str) -> Step { + Step { text, tool: None, command: None } +} + +/// A step that uses tool `id`. +const fn tool(text: &'static str, id: &'static str) -> Step { + Step { text, tool: Some(id), command: None } +} + +/// A step that runs command `id`. +const fn command(text: &'static str, id: &'static str) -> Step { + Step { text, tool: None, command: Some(id) } +} + +/// Every skill, in the order the Skills tab lists them. +pub const SKILLS: &[Skill] = &[ + Skill { + id: "essentials", + title: "Best of VectorCraft", + tutorials: &[ + Tutorial { + id: "draw-and-color", + title: "Draw and color a shape", + steps: &[ + tool("Choose the Rectangle tool in the toolbar.", "rectangle"), + say("Drag on the artboard to draw a rectangle."), + say("With the rectangle still selected, click a color in the Swatches panel to fill it."), + command("Click the Stroke box under the tools, then click another swatch to color the outline.", "paint.toggleActive"), + ], + }, + Tutorial { + id: "combine-shapes", + title: "Combine shapes into one", + steps: &[ + tool("Choose the Ellipse tool and draw two circles that overlap.", "ellipse"), + tool("Choose the Selection tool and drag across both circles to select them.", "selection"), + tool("Choose the Shape Builder tool and drag across the parts you want to merge.", "shapeBuilder"), + ], + }, + Tutorial { + id: "add-headline", + title: "Add a headline", + steps: &[ + tool("Choose the Type tool and click on the artboard.", "type"), + say("Type a few words, then press Escape to finish."), + say("Pick a font and a size in the Character panel."), + ], + }, + ], + }, + Skill { + id: "draw", + title: "Draw and build", + tutorials: &[ + Tutorial { + id: "pen-paths", + title: "Draw paths with the Pen", + steps: &[ + tool("Choose the Pen tool.", "pen"), + say("Click to place corner points, or drag to pull out a curve."), + say("Click the first point again to close the shape, or press Enter to leave it open."), + ], + }, + Tutorial { + id: "smooth-curves", + title: "Draw smooth curves", + steps: &[ + tool("Choose the Curvature tool.", "curvature"), + say("Click to place points: the path bends smoothly through each one."), + tool("Choose the Direct Selection tool and drag a point to reshape the curve.", "directSelection"), + ], + }, + Tutorial { + id: "pencil-sketch", + title: "Sketch with the Pencil", + steps: &[ + tool("Choose the Pencil tool.", "pencil"), + say("Drag to draw a freehand line."), + tool("Choose the Smooth tool and drag along the line to even it out.", "smooth"), + ], + }, + ], + }, + Skill { + id: "color", + title: "Apply color", + tutorials: &[ + Tutorial { + id: "fill-and-stroke", + title: "Fill and stroke", + steps: &[ + tool("Select an object with the Selection tool.", "selection"), + say("Click the Fill box under the tools, then pick a color in the Color panel."), + command("Click the double arrow beside the boxes to swap the fill and the stroke.", "paint.swap"), + ], + }, + Tutorial { + id: "gradient", + title: "Add a gradient", + steps: &[ + tool("Select an object with the Selection tool.", "selection"), + tool("Choose the Gradient tool and click the object to give it a gradient.", "gradient"), + say("Drag across the object to set the gradient's direction and length."), + ], + }, + Tutorial { + id: "copy-colors", + title: "Copy colors from other art", + steps: &[ + tool("Select the object to color with the Selection tool.", "selection"), + tool("Choose the Eyedropper tool and click the art whose colors you want.", "eyedropper"), + ], + }, + ], + }, + Skill { + id: "type", + title: "Set type", + tutorials: &[ + Tutorial { + id: "area-type", + title: "Flow text into a shape", + steps: &[ + tool("Draw a rectangle for the text to fill.", "rectangle"), + tool("Choose the Area Type tool and click the rectangle's edge.", "areaType"), + say("Type or paste your text: it wraps inside the shape."), + ], + }, + Tutorial { + id: "type-on-path", + title: "Type along a path", + steps: &[ + tool("Draw a curved path with the Pen tool.", "pen"), + tool("Choose the Type on a Path tool and click the path.", "typeOnPath"), + say("Type your text: it follows the path."), + ], + }, + Tutorial { + id: "outline-type", + title: "Turn type into shapes", + steps: &[ + tool("Select a text object with the Selection tool.", "selection"), + command("Choose Type › Create Outlines to turn its letters into paths.", "type.createOutlines"), + ], + }, + ], + }, +]; + +/// The skill whose id is `id`. +pub fn skill(id: &str) -> Option<&'static Skill> { + SKILLS.iter().find(|s| s.id == id) +} + +/// The tutorial whose id is `id`, with its skill. +pub fn tutorial(id: &str) -> Option<(&'static Skill, &'static Tutorial)> { + SKILLS.iter().find_map(|s| s.tutorials.iter().find(|t| t.id == id).map(|t| (s, t))) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs new file mode 100644 index 0000000000..d4dbf8f00b --- /dev/null +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -0,0 +1,231 @@ +use std::collections::HashSet; + +use egui::accesskit::Node; +use serde_json::json; +use vectorcraft_engine::Session; + +use super::{ID, Tab, View, catalog, show}; +use crate::VectorcraftApp; +use crate::tests_labels::painted_text; + +const SCREEN: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 800.0)); + +fn app() -> VectorcraftApp { + crate::i18n::set_current(crate::i18n::Lang::EN); + VectorcraftApp::new(Session::new(), Default::default()) +} + +/// One frame of `draw` with screen-reader nodes on → the nodes. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(SCREEN), ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +/// The center of the node labeled `name`. +fn center(nodes: &[Node], name: &str) -> Option { + center_where(nodes, |label| label == name) +} + +/// The center of the first node whose label `pick` takes. +fn center_where(nodes: &[Node], pick: impl Fn(&str) -> bool) -> Option { + let b = nodes.iter().find(|n| n.label().is_some_and(&pick))?.bounds()?; + Some(egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) +} + +/// Click the node labeled `name` in what `draw` draws. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + frame(app, ctx, vec![], draw); + let at = center(&frame(app, ctx, vec![], draw), name).unwrap_or_else(|| panic!("{name} is drawn")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at), press(true)], draw); + frame(app, ctx, vec![press(false)], draw); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + ctx +} + +fn app_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + crate::chrome::app_bar(app, ui); +} + +fn shown(app: &VectorcraftApp) -> bool { + crate::panel_docking::showing(&app.ui, ID) +} + +/// Every skill has tutorials and every tutorial steps; ids are unique; every tool and command a +/// step names exists. +#[test] +fn the_catalog_is_valid() { + let mut skills = HashSet::new(); + let mut tutorials = HashSet::new(); + assert!(!catalog::SKILLS.is_empty()); + for skill in catalog::SKILLS { + assert!(skills.insert(skill.id), "skill id {} is used twice", skill.id); + assert!(!skill.title.is_empty() && !skill.tutorials.is_empty(), "skill {} is empty", skill.id); + for tutorial in skill.tutorials { + assert!(tutorials.insert(tutorial.id), "tutorial id {} is used twice", tutorial.id); + assert!(!tutorial.title.is_empty() && !tutorial.steps.is_empty(), "tutorial {} is empty", tutorial.id); + for step in tutorial.steps { + assert!(step.text.ends_with('.'), "{}: {:?}", tutorial.id, step.text); + if let Some(tool) = step.tool { + assert!(vectorcraft_tools::catalog::tool_info(tool).is_some(), "{}: no tool {tool}", tutorial.id); + } + if let Some(cmd) = step.command { + let known = + crate::menus::UI_COMMANDS.iter().any(|c| c.0 == cmd) || vectorcraft_engine::cmd::command_specs().iter().any(|c| c.id == cmd); + assert!(known, "{}: no command {cmd}", tutorial.id); + } + } + } + } +} + +/// Explore tutorials at the right end of the app bar shows the Learn panel, and hides it again. +#[test] +fn explore_tutorials_on_the_app_bar_shows_and_hides_the_learn_panel() { + let mut app = app(); + let ctx = context(); + assert!(!shown(&app)); + let nodes = frame(&mut app, &ctx, vec![], app_bar); + let search = center_where(&nodes, |label| label.starts_with("Search commands and tools")); + let learn = center(&nodes, "Explore tutorials"); + let (search, learn) = (search.expect("the search icon shows"), learn.expect("the app bar has Explore tutorials")); + assert!(learn.x < search.x && (learn.y - search.y).abs() < 1.0, "beside the search icon: {learn:?}, {search:?}"); + click(&mut app, &ctx, "Explore tutorials", app_bar); + assert!(shown(&app), "{}", app.ui.status); + click(&mut app, &ctx, "Explore tutorials", app_bar); + assert!(!shown(&app)); +} + +/// The command and params of Window › Learn. +fn window_learn() -> (&'static str, serde_json::Value) { + use crate::menus::Item; + let window = crate::menus::menu_tree().into_iter().find(|(title, _)| *title == "Window").map(|(_, items)| items).unwrap(); + window + .iter() + .find_map(|item| match item { + Item::Cmd("Learn", cmd, p) => Some((*cmd, p.clone())), + _ => None, + }) + .expect("Window › Learn") +} + +/// Window › Learn shows and hides the panel, as `window.learn` does. +#[test] +fn window_learn_shows_and_hides_the_panel() { + let mut app = app(); + let (cmd, params) = window_learn(); + app.run(cmd, params.clone()).unwrap(); + assert!(shown(&app)); + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(true), "Window › Learn is checked"); + app.run(cmd, params.clone()).unwrap(); + assert!(!shown(&app)); + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(false)); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true)); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true), "shown stays shown"); + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(app.run("window.learn", json!({"show": "yes"})).is_err()); + assert!(!shown(&app)); +} + +/// With the dock hidden, Learn as the front panel is not visible: `window.learn`, Window › Learn +/// and Explore tutorials show the dock instead of closing the panel. +#[test] +fn window_learn_shows_a_hidden_dock() { + let mut app = app(); + let (cmd, params) = window_learn(); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true)); + app.ui.dock = false; + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(false), "Window › Learn is unchecked"); + assert_eq!(app.run("window.learn", json!({"show": false})).unwrap(), json!(false)); + assert!(shown(&app) && !app.ui.dock, "hiding a panel that cannot be seen changes nothing"); + app.run(cmd, params).unwrap(); + assert!(app.ui.dock && shown(&app), "Window › Learn shows the dock"); + // A full screen mode hides the panels: the panel stays the front one and shows when it ends. + app.ui.screen_mode = 3; + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(shown(&app)); + app.ui.screen_mode = 0; + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(!shown(&app)); +} + +/// The For you tab's Quick skill guide card opens the Skills tab, which lists every skill with its +/// count of tutorials. +#[test] +fn the_skills_tab_lists_every_skill() { + let mut app = app(); + let ctx = context(); + let text = painted_text(&mut app, show); + assert!(text.contains("Get started") && text.contains("Quick skill guide"), "{text}"); + click(&mut app, &ctx, "Quick skill guide", show); + assert_eq!(app.ui.learn.tab, Tab::Skills); + let text = painted_text(&mut app, show); + for skill in catalog::SKILLS { + assert!(text.contains(skill.title), "{} in {text}", skill.title); + assert!(text.contains(&format!("0/{}", skill.tutorials.len())), "{text}"); + } + let skill = &catalog::SKILLS[1]; + click(&mut app, &ctx, skill.title, show); + let text = painted_text(&mut app, show); + for tutorial in skill.tutorials { + assert!(text.contains(tutorial.title), "{} in {text}", tutorial.title); + } +} + +/// Choosing a tutorial previews its numbered steps; Back goes to its skill, then to the skills. +#[test] +fn a_tutorial_previews_its_steps() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }; + click(&mut app, &ctx, tutorial.title, show); + assert_eq!(app.ui.learn.tutorial, Some(tutorial.id)); + let text = painted_text(&mut app, show); + for (i, step) in tutorial.steps.iter().enumerate() { + assert!(text.contains(&format!("{}.", i + 1)) && text.contains(step.text), "{} in {text}", step.text); + } + click(&mut app, &ctx, "Back", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + click(&mut app, &ctx, "Back", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }); +} + +/// `learn.show` opens the panel on a tutorial's steps; `learn.list` lists every tutorial. +#[test] +fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { + let mut app = app(); + let shown_tutorial = app.run("learn.show", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(shown_tutorial["skill"], "color"); + assert_eq!(shown_tutorial["steps"].as_array().map(Vec::len), Some(3)); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") }); + let list = app.run("learn.list", json!({})).unwrap(); + let skills = list["skills"].as_array().unwrap(); + assert_eq!(skills.len(), catalog::SKILLS.len()); + let count: usize = skills.iter().map(|s| s["tutorials"].as_array().map_or(0, Vec::len)).sum(); + assert_eq!(count, catalog::SKILLS.iter().map(|s| s.tutorials.len()).sum::()); + assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); +} + +/// A missing, mistyped or unknown tutorial id is an error and leaves the panel as it was. +#[test] +fn learn_show_rejects_a_missing_or_unknown_tutorial() { + let mut app = app(); + let before = app.ui.learn.clone(); + for (params, error) in [ + (json!({}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": 3}), "`tutorial` must be a string"), + (json!({"tutorial": "nope"}), "unknown tutorial `nope` (see learn.list)"), + ] { + assert_eq!(app.run("learn.show", params).unwrap_err(), error); + assert_eq!(app.ui.learn, before); + assert!(!shown(&app)); + } +} diff --git a/crates/ui-egui/src/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/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 31976746e3..f28e7e5540 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,8 +155,9 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "geometric-patterns", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); + assert_eq!(neighbour::(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 32d15d715f..cabca387ae 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Korean lines break between any two syllables, not only between words. + let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { + toggle_korean_line_break_anywhere(app); + } } ui.separator(); let composer = shared_composer(app); @@ -899,12 +904,20 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph +/// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for +/// all of them otherwise, in one undo step. +fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + format(app, json!({"koreanLineBreakAnywhere": !on})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi /// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside -/// an undo group that is already open, as part of its step). +/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -924,7 +937,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 347975f680..517ef1eeab 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -133,6 +133,39 @@ fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); } +/// Korean Line Break Anywhere: new type has it off; the menu item turns it on for the paragraphs +/// the Type tool's selection touches in one undo step, is unchecked while the selected paragraphs +/// differ, turns it on for all of them then and off again once they all have it. Reset Panel turns +/// it off. +#[test] +fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("가나\n다라\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).korean_line_break_anywhere, text.para_at(1).korean_line_break_anywhere) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.korean_line_break_anywhere), None, "mixed: unchecked"); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6dcd4a516f..07b53cc6d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -159,6 +159,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { crate::panels::image_trace::trace_button(app, ui, ui.available_width()); actions.push((tl!("Crop Image"), "ui.cropImage")); actions.push((tl!("Mask"), "object.maskImage")); + // Remove Background at once with its defaults, as the reference app's Quick Actions do + // (). + actions.push((tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND)); } // Object › Path's Offset Path and Simplify take paths: type alone could only fail, and offsets // live through the Appearance panel's Effect › Path › Offset Path (#2238). diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 7e58a218a5..6a984a1ced 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -20,7 +20,7 @@ const ALIGNS: [(TabAlign, &str); 4] = [(TabAlign::Left, "⇥"), (TabAlign::Cente /// The name of alignment `a`'s button: its tool tip and what screen readers call it. A line of /// vertical type runs from top to bottom, so in vertical type a Left stop is named Top-Justified /// and a Right stop is named Bottom-Justified. -fn align_name(a: TabAlign, vertical: bool) -> &'static str { +pub(crate) fn align_name(a: TabAlign, vertical: bool) -> &'static str { match (a, vertical) { (TabAlign::Left, false) => tl!("Left-Justified Tab"), (TabAlign::Left, true) => tl!("Top-Justified Tab"), @@ -686,15 +686,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // A click that lands on a stop's whole point or tick selects that stop and records no // undo step. let added = !stops.iter().any(|s| s.position == position); - if added { - stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); - stops.sort_by(|a, b| a.position.total_cmp(&b.position)); - } - let i = stops.iter().position(|s| s.position == position).unwrap_or(0); - set_pstate::>(ui.ctx(), "tabs-marker-sel", None); - set_pstate(ui.ctx(), "tabs-sel", i); - if added { - apply(app, &stops); + let max = TabStop::MAX_STOPS; + if added && stops.len() >= max { + // A full list takes no more stops: the click leaves the stops and the + // selection as they are and shows the limit. + app.status(format!("at most {max} tab stops")); + } else { + if added { + stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + let i = stops.iter().position(|s| s.position == position).unwrap_or(0); + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); + if added { + apply(app, &stops); + } } } } @@ -729,7 +736,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the // previous stop, or from the paragraph's left indent plus its list level's indent when it is - // the first stop. + // the first stop. The copies stop at the 256-stop limit, which the status bar then shows. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { 0 => (stops[0].position - left_indent).abs(), @@ -738,8 +745,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { .max(1.0); let base = stops[sel].clone(); stops.truncate(sel + 1); + let max = TabStop::MAX_STOPS; let mut pos = base.position + step; while pos <= base.position + step * 20.0 && pos < 2000.0 { + if stops.len() >= max { + app.status(format!("at most {max} tab stops")); + break; + } stops.push(TabStop { position: pos, ..base.clone() }); pos += step; } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 9e96866ab2..97e8c8a00a 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1255,6 +1255,35 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { assert_eq!(current(&app).unwrap().1, before); } +/// With 256 stops, a click on an empty part of the ruler adds none and shows the limit in the +/// status bar. Repeat Tab near the end of the list adds copies only up to 256 stops and shows the +/// limit too. +#[test] +fn the_ruler_and_repeat_tab_stop_at_256_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + // 250 stops 1 pt apart from 0 pt, then six 2 pt apart from 260 pt. + let start: Vec = (0..250).map(f64::from).chain((0..6).map(|i| 260.0 + 2.0 * f64::from(i))).collect(); + let stops: Vec = start.iter().map(|p| json!({"position": p})).collect(); + app.session.execute("text.tabs.set", &json!({ "stops": stops })).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + set_pstate(&ctx, "tabs-sel", 3usize); + drag_on_ruler(&ctx, &mut app, 290.0, 290.0, false); + assert_eq!(positions(&app), start); + assert_eq!(pstate::(&ctx, "tabs-sel"), 3); + assert_eq!(app.ui.status, "at most 256 tab stops"); + // Repeat Tab on the stop at 260 pt (11 pt past the one before it): five copies, not 20. + app.status(""); + set_pstate(&ctx, "tabs-sel", 250usize); + choose(&ctx, &mut app, "Repeat Tab"); + let repeated = positions(&app); + assert_eq!(repeated.len(), 256); + assert_eq!(repeated[249..], [249.0, 260.0, 271.0, 282.0, 293.0, 304.0, 315.0]); + assert_eq!(app.ui.status, "at most 256 tab stops"); +} + /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's /// left indent (18 pt further in for each list level past the first), from the paragraph the /// caret is in while the Type tool edits text. 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/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..4d8959f262 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -132,6 +132,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { [ json!("art-ribbons"), json!("calligraphic-nibs"), + json!("bristle-brushes"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), diff --git a/crates/ui-egui/src/panels/tests_css_properties.rs b/crates/ui-egui/src/panels/tests_css_properties.rs index 619be4d70b..e03fc9a59c 100644 --- a/crates/ui-egui/src/panels/tests_css_properties.rs +++ b/crates/ui-egui/src/panels/tests_css_properties.rs @@ -146,3 +146,23 @@ fn export_says_in_the_status_bar_when_an_image_could_not_be_decoded() { assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 1 note: {WARNING}")); assert_eq!(log.borrow().len(), 3, "the sheet and the image's picture are still written"); } + +/// A CSS export with several notes: the status bar names the first, counts them, and keeps every +/// one of them for its hover. +#[test] +fn export_keeps_every_note_for_the_hover() { + let (mut app, _) = app(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({})); + // Type painted with a gradient is rasterized, and its picture is drawn in the fallback font. + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + app.run("paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap(); + let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 2 notes: {}", notes[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); +} diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index a776819381..46c274dee6 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -101,6 +101,30 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style() { + let mut app = app(); + let entry = crate::menus::menu_entries(&app) + .into_iter() + .find(|e| e.path == ["Window", "Graphic Style Libraries"] && e.label == "Painted and Sketched") + .expect("a Window menu item"); + assert_eq!((entry.command.as_deref(), &entry.params["library"]), (Some("window.graphicStyleLibrary"), &json!("painted-sketched"))); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run(entry.command.as_deref().unwrap(), entry.params.clone()).unwrap(); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("graphicStyles", "painted-sketched"))); + let ctx = context(); + let undo = undo_len(&app); + click(&mut app, &ctx, "Charcoal Shade", Modifiers::NONE, 0.0); + let (g, linked) = app.session.selection_graphic_style().expect("applied to the selection"); + assert_eq!((g.name.as_str(), linked), ("Charcoal Shade", true)); + assert_eq!(undo_len(&app), undo + 1, "added and applied as one undo step"); + let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone(); + assert_eq!(node.iter().map(|e| e.id.as_str()).collect::>(), ["sketch.charcoal"]); + let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1; + let rows = GraphicStyleLibraries::rows(&lib, "ink"); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ink Contour", "Wet Ink"]); +} + #[test] fn the_window_menu_and_the_panel_open_every_library() { let mut app = app(); diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..d2b0a26d3f 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,20 @@ pub(crate) struct Picks { replacing: Option, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option, + /// The files that the OK of the question whether to replace the files a save or export writes + /// beside the one picked allows to be replaced while it runs that again + /// ([`crate::dialogs::confirm::ask_replace_files`]). + pub(crate) replace_files_ok: Vec, + /// The files each document's last save wrote beside its own file, by document uid: its next + /// save replaces them without asking. + pub(crate) written_beside: std::collections::HashMap>, + /// 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 +169,24 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.replace_files_ok.clear(); + 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 +223,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 +377,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 +425,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 +437,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; }); @@ -388,7 +446,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Make the document an entry asked for (`doc`, by its uid) active again before it runs again: /// another may be active by now (opened from the Finder, by an agent), or it may be gone → false /// when it can't run (the status bar says why). -fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { +pub(crate) fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { let Some(uid) = doc else { return true }; let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else { app.status(tl!("The document was closed while its file dialog was open")); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index ebb42852a9..de9407e2f3 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -577,8 +577,8 @@ pub fn image_summary(i: &Value) -> (String, String) { } /// The Control bar for the one selected image: Linked File or Embedded, its file name, colour mode -/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask and -/// Crop Image. Whether one image is selected. +/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask, +/// Crop Image and Remove Background. Whether one image is selected. pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let Some(i) = selected_image_info(app) else { return false }; let (name, details) = image_summary(i); @@ -595,7 +595,11 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool ui.separator(); link_buttons(app, ui, id, linked, &name, None); crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W); - for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "ui.cropImage", 84.0)] { + for (label, cmd, w) in [ + (tl!("Mask"), "object.maskImage", 52.0), + (tl!("Crop Image"), "ui.cropImage", 84.0), + (tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND, 128.0), + ] { if widgets::flat_button(ui, label, w).clicked() { crate::menus::invoke(app, cmd, json!({})); } diff --git a/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..3e74ad394f 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,10 +43,14 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; -pub type ExistsFn = Box bool>; +/// Shared, so Background Save and Export can check from a worker thread too. +pub type ExistsFn = Arc bool + Send + Sync>; 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>>; @@ -86,14 +90,21 @@ pub struct Services { pub read_head: Option, pub write: Option, /// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a - /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). - /// Without it, no file counts as there. + /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]), and + /// before a save or export replaces a file it writes beside that one (a file per artboard, a + /// linked image). Without it, no file counts as there. pub exists: Option, - /// 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 +190,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/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index df87187a65..83d53d700c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -194,6 +194,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); note_key_surfaces(app, ctx); + forget_opening_key(ctx); let focused = ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT; // A held tool shortcut that is let go returns to the tool active before it. held_tool::follow(app, ctx, focused); @@ -364,6 +365,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let ran = !hits.is_empty(); for c in hits { run_hit(app, ctx, &c); + if dialog_or_palette_open(app) { + break; + } } if settings_on_a_mac(app, ctx, mac(ctx)) || ran { return; @@ -423,10 +427,54 @@ fn note_key_surfaces(app: &VectorcraftApp, ctx: &egui::Context) { /// Removes this frame's presses of Enter and Escape when the key went down on something that has /// since changed: the press that opened or closed a dialog, and its repeats until the key is let -/// go, reach neither the new dialog nor what is under the old one. +/// go, reach neither the new dialog nor what is under the old one. Also removes the repeats of a +/// key without a modifier that opened a dialog or the palette ([`hold_back_opening_key`]). pub(crate) fn hold_back_keys(app: &VectorcraftApp, ctx: &egui::Context) { hold_back_key(app, ctx, Key::Enter); hold_back_key(app, ctx, Key::Escape); + hold_back_opening_key(ctx); +} + +/// The key of the mark of a key without a modifier whose command opened a dialog or the palette. +fn opening_key_id(ctx: &egui::Context) -> egui::Id { + egui::Id::unique("opening-key").with(ctx.viewport_id()) +} + +/// Removes this frame's repeats of the key without a modifier that opened a dialog or the +/// palette, and the text they type, until that key is let go: a held key does not type into the +/// field that takes the keyboard. +fn hold_back_opening_key(ctx: &egui::Context) { + let id = opening_key_id(ctx); + let Some(key) = ctx.data(|m| m.get_temp::(id)) else { return }; + let repeat_of = |e: Option<&egui::Event>| matches!(e, Some(egui::Event::Key { key: k, pressed: true, repeat: true, .. }) if *k == key); + ctx.input_mut(|i| { + let typed_by = typing_presses(&i.events); + let held: Vec = i + .events + .iter() + .zip(typed_by) + .map(|(e, by)| repeat_of(Some(e)) || matches!(e, egui::Event::Text(_)) && repeat_of(by.and_then(|k| i.events.get(k)))) + .collect(); + let mut held = held.into_iter(); + i.events.retain(|_| !held.next().unwrap_or(false)); + }); + // A repeat that comes in the same frame as the release is held back above; later presses are new. + let released = + ctx.input(|i| !i.key_down(key) || i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, pressed: false, .. } if *k == key))); + if released { + ctx.data_mut(|m| m.remove::(id)); + } +} + +/// Forgets the key that opened a dialog or the palette ([`hold_back_opening_key`]) once it is up +/// and this frame has none of its events. +fn forget_opening_key(ctx: &egui::Context) { + let id = opening_key_id(ctx); + if let Some(key) = ctx.data(|m| m.get_temp::(id)) + && ctx.input(|i| !i.key_down(key) && !i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, .. } if *k == key))) + { + ctx.data_mut(|m| m.remove::(id)); + } } fn hold_back_key(app: &VectorcraftApp, ctx: &egui::Context, key: Key) { @@ -549,17 +597,30 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; + // A command runs as it does on a chord ([`run_hit`]): what its menu item runs. Smooth opens + // its slider, and Move opens its dialog. match hit.id.strip_prefix("tool:") { Some(tool) => held_tool::choose(app, ctx, tool, pressed, repeat), - None if let Some((id, p)) = menu_target(hit) => crate::menus::invoke(app, &id, p), - None if hit.params.is_null() => { - let _ = app.run(&hit.id, json!({})); + None => { + run_hit(app, ctx, hit); + if dialog_or_palette_open(app) { + if let Some(key) = pressed { + let id = opening_key_id(ctx); + ctx.data_mut(|m| m.insert_temp(id, key)); + } + break; + } } - None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)), } } } +/// Is a dialog or the palette open? A shortcut that opens one stops the shortcuts after it in the +/// same frame. +fn dialog_or_palette_open(app: &VectorcraftApp) -> bool { + app.ui.dialog.is_some() || app.ui.palette_open +} + /// Route Alt+arrows to spacing controls when text objects are selected. fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let m = ctx.input(|i| i.modifiers); @@ -615,6 +676,9 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd); for c in hits { run_hit(app, ctx, &c); + if dialog_or_palette_open(app) { + break; + } } settings_on_a_mac(app, ctx, mac(ctx)); } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 44d438b73b..8d32a4de22 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -292,6 +292,200 @@ fn a_shortcut_runs_what_its_menu_item_runs() { } } +/// Commands whose menu item opens a slider or a dialog before anything changes: the sliders of +/// Smooth and Simplify, Offset Path, the Object › Transform dialogs, Average, Split Into Grid, +/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into +/// Dashes, Find Nearest Swatch, and four "…" items whose menu entry gives params. +const MENU_DIALOG_COMMANDS: [&str; 22] = [ + "object.path.smooth", + "object.path.simplify", + "object.path.offsetPath", + "object.move", + "object.rotate", + "object.scale", + "object.reflect", + "object.shear", + "object.transformEach", + "path.average", + "path.averageJoin", + "object.path.splitIntoGrid", + "object.vectorHalftone", + "artboard.rearrange", + "object.pathfinder.options", + "addon.arrange.grid", + crate::addons::pathtools::dashes::COMMAND, + crate::addons::color::nearest::COMMAND, + "object.path.cleanUp", + "object.mesh.create", + "type.smartPunctuation", + "edit.findReplace", +]; + +/// F pressed without a modifier. +fn press_f() -> Vec { + vec![ + egui::Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::Text("f".into()), + ] +} + +/// An app with a selected rectangle where `id` has the shortcut `shortcut` and F runs nothing else. +fn app_with_shortcut(id: &str, shortcut: &str) -> VectorcraftApp { + let mut app = app_with_a_selected_rectangle(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, id, shortcut); + app.ui.status.clear(); + app +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// A command given a key without a modifier runs what its menu item runs, as it does on a chord: +/// its slider or dialog opens and nothing changes until it is confirmed. +#[test] +fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { + let chord = vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ]; + let mut failures = vec![]; + for id in MENU_DIALOG_COMMANDS { + let mut opened = |shortcut: &str, events: Vec| { + let mut app = app_with_shortcut(id, shortcut); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + if undo_len(&app) != before { + failures.push(format!("{id} on {shortcut} changed the document: {}", app.ui.status)); + } + app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.get("__command").cloned())) + }; + let by_key = opened("F", press_f()); + let by_chord = opened("Cmd+Alt+Shift+F9", chord.clone()); + if by_key.is_none() || by_key != by_chord { + failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// Smooth on F opens its slider on a jagged path without smoothing it; confirming the slider keeps +/// the smoothed path as one undo step. +#[test] +fn smooth_on_a_single_key_waits_for_its_slider() { + let mut app = app_with_shortcut("object.path.smooth", "F"); + app.session.execute("edit.clear", &json!({})).unwrap(); + let anchors: Vec<_> = + (0..=60).map(|i| json!({"x": 20 + i * 5, "y": 150.0 + 40.0 * (f64::from(i) / 10.0).sin() + f64::from(i % 2) * 3.0})).collect(); + app.session.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let before = undo_len(&app); + frame(&mut app, press_f()); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(open_dialog(&app).as_deref(), Some(crate::dialogs::smooth_slider::KIND), "{}", app.ui.status); + assert_eq!(undo_len(&app), before, "the path is not smoothed yet"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(undo_len(&app), before + 1, "the slider's result is one undo step"); +} + +/// A command without a dialog still runs at once on a key without a modifier: Group given F, and +/// Swap Fill and Stroke on its default Shift+X. +#[test] +fn a_single_key_shortcut_without_a_dialog_runs_at_once() { + let mut app = app_with_shortcut("object.group", "F"); + let before = undo_len(&app); + frame(&mut app, press_f()); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "F groups: {}", app.ui.status); + let mut app = app_with_a_selected_rectangle(); + let before = undo_len(&app); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::X, physical_key: Some(Key::X), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text("X".into()), + ], + ); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "Shift+X swaps fill and stroke: {}", app.ui.status); +} + +/// With no document open, a command's key without a modifier reports what the same command +/// reports on a chord. +#[test] +fn a_single_key_shortcut_reports_what_its_chord_reports() { + let status = |shortcut: &str, events: Vec| { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "paint.default", shortcut); + app.ui.status.clear(); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + (open_dialog(&app), app.ui.status.clone()) + }; + let by_key = status("F", press_f()); + let by_chord = status( + "Cmd+Alt+Shift+F9", + vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ], + ); + assert_eq!(by_key, by_chord); + assert!(!by_key.1.is_empty(), "the chord reports something: {by_chord:?}"); +} + +/// Holding a key without a modifier that opens a dialog types nothing into the dialog: neither the +/// key's character nor its repeats reach the field that takes the keyboard. +#[test] +fn holding_a_single_key_that_opens_a_dialog_types_nothing_into_it() { + // Move on F: the dialog and the text of the field with the keyboard after the press, eight + // more frames with F held down (repeating) or not, and the release, which comes in the same + // frame as a last repeat. + let hold = |repeats: bool| { + let mut app = app_with_shortcut("object.move", "F"); + let held = HeldKey::new(Key::F, Modifiers::NONE); + held.frame(&mut app, press_f()); + for _ in 0..8 { + held.frame(&mut app, if repeats { press_f() } else { vec![] }); + } + let release = egui::Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: Modifiers::NONE }; + held.frame(&mut app, if repeats { [press_f(), vec![release]].concat() } else { vec![release] }); + let field = held.ctx.memory(|m| m.focused()).and_then(|id| held.ctx.data_mut(|d| d.get_temp::(id))); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + (open_dialog(&app), field, app.ui.status.clone()) + }; + let let_go = hold(false); + assert_eq!(let_go.0.as_deref(), Some("move"), "{}", let_go.2); + assert!(let_go.1.is_some(), "its first field has the keyboard"); + assert_eq!(hold(true), let_go, "the held F typed nothing into Move"); +} + +/// A key that opens a dialog stops the keys after it in the same frame: G, given Group, does not +/// group the selection behind Move's dialog. The same holds for chords. +#[test] +fn keys_after_one_that_opens_a_dialog_in_the_same_frame_run_nothing() { + let mods = cmd() | Modifiers::ALT | Modifiers::SHIFT; + let chord = |key: Key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }; + let single = |key: Key, text: &str| { + [egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }, egui::Event::Text(text.into())] + }; + let runs = [ + ("F", "G", [single(Key::F, "f"), single(Key::G, "g")].concat()), + ("Cmd+Alt+Shift+F9", "Cmd+Alt+Shift+F10", vec![egui::Event::ModifiersChanged(mods), chord(Key::F9), chord(Key::F10)]), + ]; + for (move_key, group_key, events) in runs { + let mut app = app_with_shortcut("object.move", move_key); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "object.group", group_key); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app).as_deref(), undo_len(&app)), (Some("move"), before), "{move_key} then {group_key}: {}", app.ui.status); + } +} + /// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard /// tool's Shift+O), with the Selection tool or while the Type tool edits the text. #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 52e492d4d3..4a0e73da6b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -209,6 +209,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), (crate::panels::script_console::ID, "Script Console", "message-circle"), + (crate::panels::learn::ID, "Learn", "graduation-cap"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), (crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"), @@ -401,6 +402,9 @@ pub struct UiState { /// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs. #[serde(skip)] pub find_font_in: String, + /// The Learn panel's tab, skill and tutorial, kept while the app runs. + #[serde(skip)] + pub learn: crate::panels::learn::View, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -537,8 +541,9 @@ pub struct UiState { /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. #[serde(skip)] pub replace_font_warning: Option, - /// The notes of the last open that gave any, with the status text that names the first of - /// them: hovering that text lists them when there are several. Not saved. + /// The notes of the last open that gave any, or of the last save or export, with the status + /// text that names the first of them: hovering that text lists them when there are several. + /// Not saved. #[serde(skip)] pub status_notes: Option<(String, Vec)>, pub palette_open: bool, @@ -1030,6 +1035,7 @@ impl Default for UiState { transient_focus: false, color_settings_scroll: false, find_font_in: String::new(), + learn: Default::default(), control_bar: false, control_bar_bottom: false, control_bar_hidden_groups: Default::default(), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index ee876b2e71..26406a4992 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -107,6 +107,27 @@ fn control_saves_take_the_native_save_options() { let _ = std::fs::remove_dir_all(base); } +/// A save without a path writes the document's own file in the write root: `app.save` and +/// `file.save` leave its path out of the reply, since it is outside the read root, and keep a path +/// the client gives. +#[test] +fn control_saves_leave_out_the_documents_file_outside_the_read_root() { + let (mut app, tx, base) = confined("save-own"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + for (method, params) in [("app.save", json!({})), ("engine.execute", json!({"command": "file.save", "params": {}}))] { + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, method, params); + assert_eq!(r["ok"], true, "{method}: {r}"); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{method}: {r}"); + assert_eq!(app.session.active().map(|d| d.is_dirty()), Some(false), "{method}: saved"); + } + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. @@ -630,9 +651,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 +673,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 +686,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 +997,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 +1012,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/background.rs b/crates/ui-egui/src/tests/background.rs index add53156e8..a71a55d1fe 100644 --- a/crates/ui-egui/src/tests/background.rs +++ b/crates/ui-egui/src/tests/background.rs @@ -122,6 +122,29 @@ fn exports_run_in_the_background_and_the_preference_turns_both_off() { assert!(app.background.jobs.is_empty()); } +/// A Background Export with a file per artboard checks for files there from its worker: one with +/// an artboard file's name is asked about once the export ends, before anything is written, and +/// the question's OK writes every file. +#[test] +fn a_background_export_asks_before_replacing_an_artboard_file() { + let gate = Gate::default(); + let (mut app, files) = app(gate.clone()); + app.services.exists = Some(Arc::new(|p: &str| p == "/out/a-B.png")); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 80, "name": "B"})).unwrap(); + let r = io::export(&mut app, Some("png"), Some("/out/a.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["background"].as_bool(), Some(true), "{r}"); + gate.open(); + background::wait_all(&mut app); + assert!(files.lock().unwrap().is_empty(), "nothing is written while it asks"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::REPLACE_FILES, "“a-B.png” already exists. Replace it?")); + crate::dialogs::confirm(&mut app).unwrap(); + background::wait_all(&mut app); + let names: Vec = files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["/out/a-Artboard-1.png", "/out/a-B.png"]); +} + /// A save that returns before the file is written still says what it writes and which options it /// does not use, as `app.save` (MCP's `save_file`) replies with it: what the format loses first, as /// the reply of a save written at once. diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 5f136d514c..e97443eb36 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -64,6 +64,98 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { assert_eq!(written.borrow().len(), 10); } +/// The paths written so far. +fn names(written: &crate::tests_svg::Written) -> Vec { + written.borrow().iter().map(|(p, _)| p.clone()).collect() +} + +/// Save As with a file per artboard: a file there with an artboard file's name, which the save +/// panel did not ask about, is asked about before anything is written. Cancel writes nothing; OK +/// writes every file, and Save to the document's own file then writes them again without asking. +#[test] +fn save_as_asks_before_replacing_an_artboard_file_that_is_there() { + let (mut app, written) = app("/tmp/doc.vectorcraft"); + three_boards(&mut app); + let there = ["/tmp/doc-B.vectorcraft", "/tmp/doc-D.vectorcraft", "/tmp/doc-E.vectorcraft"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let pick = |app: &mut crate::VectorcraftApp| { + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("separateArtboards".into(), json!(true)); + d.fields.insert("all".into(), json!(true)); + dialogs::confirm(app).unwrap() + }; + let r = pick(&mut app); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::REPLACE_FILES, "“doc-B.vectorcraft” already exists. Replace it?")); + assert_eq!(d.fields["files"], json!(["/tmp/doc-B.vectorcraft"])); + assert!(written.borrow().is_empty(), "nothing is written while it asks"); + dialogs::cancel(&mut app); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(app.session.active().unwrap().path.is_none()); + pick(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/doc.vectorcraft", "/tmp/doc-Artboard-1.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft")); + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 8); + // A new artboard's file is not one the last save wrote: Save asks about it alone. + app.run("artboard.new", json!({"x": 600, "y": 0, "width": 100, "height": 80, "name": "D"})).unwrap(); + let r = app.run("file.save", Value::Null).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-D.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + // An artboard added while it asks writes a file the question did not name: OK asks about it. + app.run("artboard.new", json!({"x": 800, "y": 0, "width": 100, "height": 80, "name": "E"})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-E.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(written.borrow().len(), 14); + assert!(!app.session.active().unwrap().is_dirty()); +} + +/// A question asked when a Background Save or Export ends waits for a dialog open then to close. +#[test] +fn the_question_waits_for_an_open_dialog() { + let (mut app, _) = app("/tmp/doc.vectorcraft"); + let uid = app.session.active().unwrap().uid; + dialogs::confirm::tell(&mut app, "Open", ""); + dialogs::confirm::ask_replace_files(&mut app, uid, &["/tmp/doc-B.vectorcraft".into()], &[], "file.save", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("message")), Some("Open".to_string())); + dialogs::cancel(&mut app); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE_FILES)); +} + +/// Export with a file per artboard asks the same way, naming every file that is there; OK writes +/// them. +#[test] +fn export_asks_before_replacing_artboard_files_that_are_there() { + let (mut app, written) = app("/tmp/art.png"); + three_boards(&mut app); + let there = ["/tmp/art-B.png", "/tmp/art-C.png"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let r = crate::io::export(&mut app, Some("png"), Some("/tmp/art.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("message").as_str(), d.str("detail").as_str(), d.str("__command").as_str()), + ("“art-B.png” already exists. Replace it?", "art-B.png\nart-C.png", "file.exportAs") + ); + assert!(written.borrow().is_empty()); + // Another document made active meanwhile: OK exports the one that asked. + let uid = app.session.active().unwrap().uid; + let question = app.ui.dialog.take(); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.ui.dialog = question; + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/art-Artboard-1.png", "/tmp/art-B.png", "/tmp/art-C.png"]); + assert_eq!(app.session.active().map(|d| d.uid), Some(uid)); +} + #[test] fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() { let (mut app, written) = app("/tmp/plain.vectorcraft"); diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index c1e3f822cd..90e6fdc78d 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -539,7 +539,7 @@ fn preset_dialogs_export_asks_off_the_ui_thread() { #[test] fn a_sheets_answer_given_its_extension_asks_before_replacing() { let (mut app, shown, written) = app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); assert!(app.run("file.save", json!({})).is_err()); shown.answer(&["/art/card"]); @@ -566,7 +566,7 @@ fn a_preset_dialogs_export_asks_before_replacing_over_it() { w.borrow_mut().push(path.to_string()); Ok(()) })), - exists: Some(Box::new(|_: &str| true)), + exists: Some(std::sync::Arc::new(|_: &str| true)), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/tests/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/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index 365cc92ef7..5e93993fe3 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -88,6 +88,27 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert_eq!(app.ruler_origin(), origin); } +/// The origin snaps as a drawn point does: dragged next to a rectangle's corner, it is set on it. +#[test] +fn ruler_origin_drag_snaps_onto_the_art() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("select.none", json!({})).unwrap(); + // The Hand tool shows no smart guides of its own as the pointer passes over the corner. + app.select_tool("hand"); + let ctx = context(); + frame(&mut app, &ctx, vec![], canvas::show); + let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.4, 99.6)); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty()); + start_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, vec![], canvas::show); + assert!(!app.session.overlays(app.view_info()).is_empty(), "the guide showing what it snapped to"); + frame(&mut app, &ctx, vec![press(to, false)], canvas::show); + assert_eq!(app.ruler_origin(), Point::new(100.0, 100.0)); + assert!(app.session.overlays(app.view_info()).is_empty()); +} + #[test] fn ruler_origin_double_click_resets_to_the_active_artboard() { let mut app = app(); @@ -132,8 +153,9 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { Escape, Focus, Document, + HideRulers, } - for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document] { + for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document, Cancel::HideRulers] { let mut app = app(); app.run("document.setRulerOrigin", json!({"x": 25, "y": 30})).unwrap(); let ctx = context(); @@ -146,6 +168,7 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { Cancel::Outside => pos2(4.0, 4.0), Cancel::Escape => { frame(&mut app, &ctx, vec![key(Key::Escape)], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty(), "the snapping guides go too"); to } Cancel::Focus => { @@ -157,6 +180,14 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); to } + Cancel::HideRulers => { + assert!(!app.session.overlays(app.view_info()).is_empty(), "the snapping guides show"); + app.ui.view.rulers = false; + frame(&mut app, &ctx, vec![], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty(), "the snapping guides go with the rulers"); + assert!(!canvas::ruler_origin_dragging(&ctx)); + to + } }; frame(&mut app, &ctx, vec![Event::PointerMoved(release), press(release, false)], canvas::show); if cancel == Cancel::Document { diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 497ac0c749..b8bb2ee6e4 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -215,7 +215,7 @@ fn a_typed_extension_of_a_format_the_save_does_not_write_is_replaced() { #[test] fn a_replaced_extension_asks_before_replacing_a_file_there() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/card.png"]), "/art/card.vectorcraft"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -254,7 +254,7 @@ fn a_document_in_another_format_saves_as_the_type_picked() { #[test] fn a_name_given_the_picked_types_extension_asks_before_replacing() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/poster.ai")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/poster.ai")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/poster", "ai"]), "/art/poster.ai"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -284,7 +284,7 @@ fn on_disk(tag: &str, typed: &str) -> (VectorcraftApp, std::path::PathBuf) { let picked = dir.join(typed).to_string_lossy().into_owned(); let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), pick_save: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), ..Default::default() }; @@ -371,7 +371,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { ]; for (cmd, params, path) in cases { let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("select.all", json!({})).unwrap(); assert!(app.run(cmd, params).is_err(), "{cmd}"); assert!(w.borrow().is_empty(), "{cmd}: nothing is written while it asks"); @@ -382,7 +382,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { } // Export As: the question's OK picks the file again, and PNG Options follows. let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("file.exportAs", json!({"format": "png"})).unwrap(); assert!(dialogs::confirm(&mut app).is_err()); let open = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.str("path"))); diff --git a/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..46fde93aad 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -766,6 +766,85 @@ 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:?}"); + } + + /// A Holographic glow's chip is clear at the corners and nearly opaque at the center; a foil's + /// chip is opaque everywhere. + #[test] + fn holographic_chips_show_a_glow_clear_at_its_edge_and_a_foil_opaque() { + use vectorcraft_color::Paint; + let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap(); + let chip = |name: &str| match &lib.swatch(name).unwrap().paint { + Paint::Gradient(g) => gradient_chip_colors(g, 20.0, 20.0, 4, 4), + p => panic!("{name}: {p:?}"), + }; + let glow = chip("Mint Halo"); + assert!([0, 4, 20, 24].iter().all(|&i| glow[i].a() == 0), "{glow:?}"); + assert!(glow[2 * 5 + 2].a() >= 240, "{glow:?}"); + let foil = chip("Prism Veil"); + assert!(foil.iter().all(|c| c.a() == 255), "{foil:?}"); + } + #[test] fn freeform_chip_mixes_points_in_the_model_they_share() { use vectorcraft_color::{Color, Freeform, FreeformPoint}; diff --git a/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/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index a6789916c6..1564025a41 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -30,7 +30,8 @@ pub enum PanelDrag { /// `place`). LibrarySymbol { library: String, name: String }, /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is - /// dropped on takes it (`brush.apply`). + /// dropped on takes it (`brush.apply`); dropped where there is no art, a scatter, art or pattern + /// brush places a copy of its art there (`brush.placeArt`). Brush { name: String, def: serde_json::Value }, /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dbaf2b48ab..6619cffd6f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,24 +107,24 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | | `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | | `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | -| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | +| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | -| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes | +| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | @@ -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,12 +797,19 @@ 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 Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. +HP-GL: Export As… lists it (`file.exportAs {format: "hpgl"}`); it has no options dialog, so `ui.dialog.confirm` asks +for the path and writes the `.plt` file with the default options (one per artboard with Use Artboards). + TIFF, BMP and Targa Options: Export As… with the TIFF, BMP or Targa format (`file.exportAs {format: "tiff"}`, then the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`), @@ -817,7 +827,7 @@ a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit` the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser` writes the HTML page to a temporary folder and opens it in the default browser (desktop). Both return the export's -`warnings`, and the status bar shows the first one as Save As shows a save's notes. +`warnings`, and the status bar shows them as Save As shows a save's notes. PSD Options: Export As… with the PSD format (`file.exportAs {format: "psd"}`, then the save dialog) opens `psdOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK @@ -1059,7 +1069,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..e7831adf1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. +> **Last updated:** 2026-11-12 · **Change:** minor (clipboard failure helper follows its Linux/BSD and test callers; previously: Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending). > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol. @@ -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 | @@ -349,6 +351,10 @@ parameters. Shipped code therefore never panics. Anything that can fail returns ### Enforced by lints +The clipboard timeout failure helper is compiled for Linux/BSD workers and unit tests; production +macOS reads synchronously. Keep helper target gates aligned with their callers instead of disabling +dead-code warnings across the workspace. + `Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with `-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed @@ -506,6 +512,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Compile the clipboard failure helper only for Linux/BSD worker callers and unit tests; keep production macOS free of the unused helper without suppressing workspace dead-code warnings. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..568eaa75e5 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,13 +181,13 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable) is 25–45 h. +angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) is 25–45 h. ## Revision history diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa682c0272..89a048bd58 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -290,8 +290,8 @@ 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 | -| 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` | +| 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) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | @@ -340,6 +340,9 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | +| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | +| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | @@ -488,25 +491,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..83afa40942 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). @@ -631,8 +630,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -646,12 +645,14 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, Eyedropper), for that instance only; - graphic style libraries (Window › Graphic Style Libraries; nine original libraries generated in code, including + graphic style libraries (Window › Graphic Style Libraries; ten original libraries generated in code, including Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, Status Effects - with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff, and Textures with Fine Grain, - Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with - the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the nine shipped, and further symbol libraries beyond Basic + with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff, Painted and Sketched with + Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink Contour, Wet Ink and Fine Pen, and Textures with + Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, + painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, + `graphicStyle.addFromLibrary`). +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). @@ -663,7 +664,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D - Classic (Revolve, Rotate, Map Art), Turntable, textures, map art and shading in 3D export. + Classic (Map Art), Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. @@ -857,6 +858,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | @@ -1097,4 +1099,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 6195428c7d..81221b6aa7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -123,6 +123,18 @@ the suite) keep the agent to the folders you give it: - Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, library and preset saves to a path, the copies `links.relink {copy}` makes. +- A path the agent gives stays in a save or export reply, with the files written beside it. Paths it did not + give are left out when they are outside the read roots. `save_file`, `document.save` and `file.save` without + a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files + written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without + one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` + of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error + for that export names the folder by its name alone. + `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as + a file) leave out a kept `folder` outside the read roots. `file.package` + names the document's file and the linked files outside the read roots by their file names in `warnings`, in + `missingLinks` and in its report. `document.info` gives `link: null` for an image linked to a file outside them, + and its Linked Images rows and text report name that file alone. `action.batch` returns the replies of its steps as these commands give them. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163). - `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and @@ -153,6 +165,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 @@ -446,7 +463,14 @@ or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseT counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and -change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +change nothing; an omitted or null `id` keeps the default. Commands that need an object id (`document.node`, the +`path.*` anchor and handle commands, `text.reshapePath`, `text.editRange`, the `object.mesh.*` point commands, +`layer.target`, `node.move`, `select.magicWand` and the others like them, and the `source`, `target`, `group` and +`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and +`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the +key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of +`node.move` are refused the same way when given; left out, they keep their defaults. `livePaint.info` reads `id` when +`group` is left out or null, and reports a missing group only when both are left out. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, @@ -509,8 +533,8 @@ 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. | -| `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. | +| `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`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | @@ -1336,6 +1360,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 @@ -1627,9 +1667,9 @@ control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, `menuCommand` or `e4x`), each with a count and its lines. - `dom.members` lists the document-model member chains as the script writes them (`app.activeDocument.layers[].name`, with `[]` for an index and `()` for a call); a variable assigned from a chain - is replaced by that chain, and a name written with `\u` escapes is read as the name it spells. `dom.types` lists - the type names the script compares with `.typename` or creates with `new`, apart from the language's, the platform's - and the script's own functions. + is replaced by that chain, and a name written with `\uXXXX` or `\u{…}` escapes is read as the name it spells. + `dom.types` lists the type names the script compares with `.typename` or creates with `new`, apart from the + language's, the platform's and the script's own functions. - `menuCommands.strings` lists the string literals the script passes to `executeMenuCommand` (`app.executeMenuCommand('…')`), each with a count and its lines, and `menuCommands.dynamic` counts the calls whose argument isn't a string literal. @@ -1641,7 +1681,10 @@ control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, template literals and XML literals that aren't closed, brackets that don't match, syntax newer than ES3 and unknown directives) and notes on its text (bytes that aren't valid UTF-8, mixed line endings, and nesting deeper than the check follows: more than 4,096 open brackets, or XML literals and the code in their braces nested more - than 4,096 levels deep). + than 4,096 levels deep). Newer syntax includes a private name (`#name`, given with its escapes read, unless the + name they spell would make the line a directive) and a `\u{…}` escape in a name (given as written). A `#` and a + name at the start of a line is a private name, not a directive, when the name starts with an escape, or when the + line is inside braces and the name is not a directive's. Each entry of `targets`, `directives`, `includes` and `problems` has the `line` it is on (a problem with the whole text has none) and, when it is in an included file, that file's path as `file`. `features`, `menuCommands.strings` @@ -1776,7 +1819,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 @@ -1837,8 +1880,13 @@ no engines, and the engines it left are dropped when their own thread next uses and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run that goes past it ends with the heap limit, and the engine stays usable. - The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps - the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run - defined is reported without a line and column. + the globals the run set before it ended. An internal error drops the engine. +- An error in code an earlier run compiled, such as a function it defined, is reported at that code's line and column + in the earlier run's script, a file it included or a file its `$.evalFile` ran, and the report's `file` names that + file. For an earlier script given as `source`, `file` is that run's name. The report's `name` and the console line's + name stay the calling run's, as in `caller.js: /path/lib.jsx:12:5: Error: kept`. Each engine keeps these places + for 8 MiB of source maps, the oldest going first; an error in code whose place is gone, or in code that `eval` or + `Function` compiled, is reported without a line and column, and the code still runs. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, @@ -1862,7 +1910,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 | @@ -2414,6 +2462,26 @@ kept with the workspace, and built-in workspaces and workspaces saved without it {"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} ``` +## Learn panel + +Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide +the Learn panel. Its For you tab holds a Get started section with a Quick skill guide card; choosing the card opens +the Skills tab. The Skills tab lists VectorCraft's own skills, each with its number of tutorials and an empty progress +ring. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and Back goes up one level. +The tutorial text is English. `window.learn {show?}` shows the panel (`show: true`), hides it (`show: false`) or +toggles it (`show` omitted or null), and answers whether it shows; Window › Learn and Explore tutorials run it. +The panel counts as hidden while the dock is hidden or a full screen mode hides the panels. Showing it shows a hidden +dock; in a full screen mode it makes Learn the front panel, which shows when the mode ends, and answers false. Any +other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab +lists them, where `steps` is the number of steps. `learn.show {tutorial}` shows the panel on its Skills tab with that +tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error +and changes nothing. Behavior reference: +[Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). + +```json +{"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} +``` + ## Ruler guides Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the @@ -2554,8 +2622,9 @@ already walks Image Trace. Short agent entry points: locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the - new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own - art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and + new art still has, less a fill or stroke the object now at that path does not draw (a change left empty is dropped); + `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit + there keeps the changes the same way; making the symbol static drops them, and so does `symbol.replace` with a different symbol (a replace with the instance's own symbol keeps them). ```json @@ -2919,6 +2988,30 @@ Brushes panel's New Brush button, its menu's New Brush… and art dropped on the Brush Options for the new brush; for a scatter, art or pattern brush they preview the art from `brush.selectionArt`. Their OK runs `brush.new {type, name, params, ids?}`, and Cancel adds no brush. +`brush.placeArt {name, x?, y?}` places a copy of the art a scatter, art or pattern brush is made of, centered on +(`x`, `y`) (default: the center of the first artboard), and selects it, in one undo step. The app runs it when a +brush is dragged from the Brushes panel onto an empty part of the canvas; dropped on a path, a brush is applied to it +(`brush.apply`). A pattern brush places its tiles left to right, 10 pt apart, in the order side, outer corner, inner +corner, start, end (only the tiles it has art for), each in a group named Side Tile, Outer Corner Tile, Inner Corner +Tile, Start Tile or End Tile. A tile with a repeat frame has an unpainted rectangle at the back of its group that marks +the frame. A symbol instance in the art is measured by its symbol's art. A calligraphic or bristle brush is refused +with "`<name>` has no art (only scatter, art and pattern brushes do)". + +`brush.replaceArt {name, ids?|id?, applyToStrokes?}` makes the selected art the brush's art and keeps its other +options; several objects become one group, as in `brush.new`. A pattern brush takes a group named for a tile, or a +group of such groups, tile by tile: an unpainted rectangle at the back of a tile group becomes that tile's repeat frame, +a tile group's art goes back without that group, and the tiles that are not given stay as they are. Of two groups named +for the same tile, the frontmost one counts. Other art becomes the side tile. When strokes use the brush and +`applyToStrokes` is `false`, the brush and its strokes stay as they were, and a copy of the brush with the new art, +named "`<name>` copy", goes after it and becomes the current brush. Otherwise, and for a brush no stroke uses, the brush +changes and its strokes redraw. It replies `{name}`, the brush that has the new art, and refuses a calligraphic or +bristle brush, an empty selection and art (or a tile it is given, such as a tile group left with only its frame's +rectangle) with no width. `brush.replaceArt` and `brush.new` measure a symbol instance in the art by its symbol's art. +In the app, art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the +Brushes panel runs it. When strokes use the brush, the app checks the art first and shows why in the status bar when +the brush cannot take it; otherwise a dialog (`brushReplaceArt`) asks: Apply to Strokes, Leave Strokes +(`applyToStrokes: false`) or Cancel. + `variable.bind`, `variable.unbind`, `variable.delete`, `dataset.set`, `dataset.update`, `dataset.delete`, `dataset.select`, `dataset.next` and `dataset.prev` add no undo step when they leave the document as it was, such as binding art to a variable it already has or applying the active data set again: the steps to redo stay, and a saved @@ -3078,8 +3171,8 @@ See [Revolve](revolve.md) for the scope and limitations. replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel -(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and -`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. +(Classic), Revolve (Classic) and Rotate (Classic) have no lighting presets: a target with any of them at `index` is an +error, nothing changes, and `effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height @@ -3111,7 +3204,7 @@ art", and a loaded file draws the source path, as it does for a solid past its l documentation names the controls and their ranges ([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)). The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps -it as a path. Extrude & Bevel (Classic) has no shadow. See [three-d.md](three-d.md#shadows). +it as a path. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. See [three-d.md](three-d.md#shadows). ```json {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}} @@ -3187,6 +3280,51 @@ an unknown surface or shading color is refused with an error and the document is {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}} ``` +## Revolve (Classic) + +`effect.apply {effect: "threeD.classicRevolve", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Revolve (Classic)…. It sweeps the profile round a vertical axis as `threeD.revolve` does, at 64 segments +for a full turn, and draws every face, back to front, with the classic surfaces. Parameters: `angle` (degrees, +0..360, default 360), `cap` (true closes the profile's open ends, false leaves the surface hollow), `offset` (points, +0..1000, default 0; the axis's distance from the profile, so a value above 0 makes a ring), `edge` (`left`, the +default, or `right`: the edge of the profile's bounds the axis is at), `rotationX/Y/Z` and `perspectiveAngle` as +above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor` and +`shadingCustom` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group +or layer, whose members turn about one shared axis, or on a fill or stroke. The public documentation describes the +dialog ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)) +and its Angle (0 to 360), Cap, Offset (0 to 1000) and Left Edge or Right Edge settings +([Revolve options](https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=283)); +the defaults are VectorCraft's own. An unknown parameter (`segments` included), a number out of range, a flag that +is not true or false, another `edge`, surface or shading color is refused with an error and the document is not +changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRevolve","item":null,"params":{"angle":270,"offset":10,"surface":"diffuse","blendSteps":64}}}} +``` + +## Rotate (Classic) + +`effect.apply {effect: "threeD.classicRotate", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Rotate (Classic)…. It turns every fill and stroke as one flat plane about the center of the object's +bounds, as `threeD.rotate` does, each in its own paint, and draws the plane from either side. Parameters: +`rotationX/Y/Z` and `perspectiveAngle` as above; `surface` (`diffuse`, the default, lights the art softly, and +`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor` and `shadingCustom` as Extrude & +Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group +or layer, whose members turn about one shared center, or on a fill or stroke. The public documentation describes the +dialog's Position and Surface settings +([Rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)), +the Position presets, rotations from -180 to 180 and Perspective from 0 to 160 +([Set 3D rotation position options](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=270)), +and No Shading and Diffuse Shading as the only surfaces for an object that uses only this effect +([Surface shading options](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=271)). +The defaults and the shading are VectorCraft's own. An unknown parameter (`highlightSize` and `drawHiddenFaces` +included), a number out of range, a flag that is not true or false, a surface other than `none` or `diffuse`, or +another shading color is refused with an error and the document is not changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRotate","item":null,"params":{"rotationX":0,"rotationY":40,"rotationZ":0,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork @@ -3328,8 +3466,9 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern library Geometric Patterns (`geometric-patterns`, category -`patterns`) holds pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, +hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. @@ -3377,7 +3516,7 @@ as a value left out. ## Graphic style libraries Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and -Rules, Hand-Drawn, Textures, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). Textures +Rules, Hand-Drawn, Textures, Painted and Sketched, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). Textures (`textures`) paints its fills and strokes with the texture effects: Fine Grain (`texture.grain`), Canvas Weave and Burlap Sack (`texture.texturizer`), Cracked Glaze (`texture.craquelure`), Mosaic Floor (`texture.mosaicTiles`), Quilted Squares (`texture.patchwork`), Leaded Glass (`texture.stainedGlass`) and Damp Paper (`sketch.waterPaper`). `graphicStyle.libraries` lists them @@ -3390,6 +3529,13 @@ the patterns the styles paint with come along. `apply: true` also applies the fi does. Library styles keep placed gradients in unit-box space, so each object gets them at the same place relative to its bounds. +Painted and Sketched (`painted-sketched`) holds Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink +Contour, Wet Ink and Fine Pen. Each is a solid fill (Bristle Ochre and Ink Contour add a 1 pt stroke) under one object +effect that repaints fill and stroke together: `artistic.watercolor`, `artistic.dryBrush`, `artistic.roughPastels`, +`sketch.charcoal`, `brushStrokes.inkOutlines`, `brushStrokes.sumiE` and `sketch.graphicPen`, in that order. The +colors and effect settings are VectorCraft's own. These are raster effects, so the document's raster effects +resolution sets how fine they draw. + ```json {"name":"run_command","arguments":{"command":"graphicStyle.library","params":{"library":"hand-drawn"}}} {"name":"run_command","arguments":{"command":"graphicStyle.addFromLibrary","params":{"library":"hand-drawn","name":"Crosshatch","apply":true}}} @@ -3429,8 +3575,8 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). @@ -5021,7 +5167,8 @@ Pen's second anchor at (150, 431), in line with the square's centre. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and clears manual kerning in the type objects in `ids`, the Type tool's selected text or the selected type objects, keeping Optical; at a caret - it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, + it resets only the kerning there: of the character before it, or at the start of a line (of the text, a paragraph, + or after a forced line break) the space before the character after it (`kerningBefore`). `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel shows it, in the same text; at a caret it leaves the text as it is. In the app, `type.highlightKerning` (Cmd+Alt+K), `type.highlightTracking` (no default key) and `type.highlightFontName` (Cmd+Alt+Shift+F) show the Character panel and @@ -5626,13 +5773,23 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp `copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the XMP packet. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`, -`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`, -`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP -packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`, -`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`, -`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that -`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled -name for a template). +`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`, +`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` +that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, +`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary +(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give +one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its +root `<title>` element (not a shape's or group's). A title is read with its white space made single spaces and +without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would +refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with +the document's name as its title, and a template opens with a new untitled name. +The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and +what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the +title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the +document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export +writes carry it (PDF `Title` and XMP `dc:title`, SVG `<title>` and Dublin Core, PNG text, EPS and `.ai` `%%Title`), +where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops +the title the file gave. `document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as `fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies). @@ -5925,8 +6082,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 +6099,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 @@ -6355,13 +6516,68 @@ from the origin, so P2 lies there when the file sets no scaling points. `SP0` draws nothing. Labels, the symbols of symbol mode (`SM`) and raster images are not drawn, the input window (`IW`) does not clip the art, the art of later pages is drawn over the first page, and hatched and patterned fills are filled solid; `warnings` lists each of these. A file named as a plot file that doesn't start with plotter -instructions is refused with "not an HP-GL file". VectorCraft does not write HP-GL. +instructions is refused with "not an HP-GL file". ```json {"name":"open_file","arguments":{"path":"/tmp/part.plt"}} {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/part.plt","at":[300,200]}}} ``` +## HP-GL export + +`document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard +(or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard +(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: +a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so +fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a +gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A +symbol instance is drawn as its symbol's art with the art's effects, in the colors of the instance's stain. +Clipping masks cut the art they clip, and the art is cut at the artboard's edges. Hidden and non-printing layers, +hidden objects, objects at 0% opacity, template layers and guides are left out, and so are placed images, with a +warning. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner, or its +center with `origin: "center"`, rounded to whole units, as the HP-GL/2 and HP RTL Reference Guide defines them +([book listing](https://informit.com/store/hp-gl-2-and-hp-rtl-reference-guide-a-handbook-for-program-9780201310146); +the [vendor's support page](https://support.hp.com/sg-en/document/c01121263) names it as the language reference). +The guide also gives the instruction syntax and the encoding of `PE` numbers that the writer follows. Art more than +8,388,607 units (about 210 m) from the origin is cut off, with a warning. + +Each color is a pen. Pen 1 is the first color the plot draws in, pen 2 the next new one, and so on, up to 255 pens; +colors past those draw with the pen of the nearest color. The files of one export share the numbering, so a color is +the same pen in each file. `pens: ["#000000", "#ff0000"]` gives the pen table instead (pen 1 first): each path draws +with the pen of the nearest color, the lower pen when two are as near. A path's color is its topmost visible stroke's, +else its topmost visible fill's, where a fill or stroke at 0% opacity does not count. A gradient's color is the average +of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray +when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path +the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one +starts at its point nearest the pen). With `insideFirst: true`, a path is drawn only after every path of the same +pen that lies inside it, for example a letter's counter before the letter and a part's holes before the part. A +path lies inside a closed path when it does not cross that path's line, every part of it lies inside that path (by +the even-odd rule) or on its line, and some part lies inside: a path touching the line around it is inside, a path +crossing it or leaving it through a corner is not, and copies of one outline are not inside each other. Without +`sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw. +Curves become straight moves no further than `curveTolerance` points from the curve (default 0.1, from 0.01 to 10). +These defaults, the 255-pen limit and the rules for pen numbers, colors and the order of paths above are +VectorCraft's own. + +`hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and +ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's +paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. An `"hpgl"` plot +with a coordinate outside −32,768 to 32,767 units (about 82 cm from the origin, the range of 16-bit numbers) is written +as it is, with a warning that older devices may not read it; this check is VectorCraft's own. `warnings` names +type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke +looks that are not drawn, art past the plotter's range, HP-GL coordinates past 16-bit numbers, colors past 255 pens, +and the limits on points, clipping work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one +work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet +drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is +finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add +a warning. + +```json +{"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/stencil.plt","sortPaths":true,"insideFirst":true}}} +``` + ## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one @@ -6491,7 +6707,8 @@ point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web -downloads it) and shows the first note in the status bar. +downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the +message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. ```json {"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}} @@ -6992,7 +7209,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]}}} @@ -7157,6 +7376,25 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Korean Line Break Anywhere + +`text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the +East Asian options) turns it on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / +`end` range touches). Off, a Korean word (the characters between two spaces, when any of them is Hangul, Han and kana +included) breaks only at its spaces, after East Asian punctuation in it, and at forced line breaks, so a word that does +not fit moves to the next line whole; a word wider than the line still breaks where it overflows. On, a line may break +between any two Hangul syllables. Both composers and vertical type follow it, No Break and the Kinsoku Set still apply, +and hyphenation never adds a hyphen to Hangul. Text without Hangul keeps its own breaks. New type and imported text have +it off; paragraphs saved before the option open with it on, the breaks they were laid out with. It is saved with the +paragraph (`korean_line_break_anywhere` in `para`) only when off, paragraph styles carry it (Paragraph Style Options › +East Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a Korean paragraph with it on as left out. The +help page: https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/set-line-breaks-korean-text.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"koreanLineBreakAnywhere":true}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / @@ -7733,12 +7971,12 @@ its look, in one undo step. Point type in the selection is left as it is, and ar ([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and -list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step +list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked while the first selected type on a path or area type has it on. `document.inspect` reports `dynamicText` and `fitScale` for type -on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the -editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says -so in a warning. +on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS, legacy +`.ai` and the editing data for other apps write the type at its drawn sizes; legacy `.ai` and the editing data do not +hold the setting, and each says so in a warning. ```json {"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}} @@ -7780,7 +8018,10 @@ touches (a caret, `start == end`, touches its paragraph). Without a range they c targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or -`alignOn` value that is not a string, and an empty `alignOn` value. +`alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than +8 characters, a position outside 0 to 100000 pt, and more than 256 stops unless every paragraph it targets already +holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and +keeps the first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in @@ -7797,7 +8038,7 @@ is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The reply lists the names `added` and `existing` in the document and the names `skipped` in the file (a style whose -attributes do not read or are out of the range `charStyle.new` takes, or whose name is empty); `textStyle.loadAll` +attributes do not read or are out of the range `charStyle.new` or `paraStyle.new` takes, or whose name is empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`, and `charStyle.load` and `paraStyle.load` list only the styles of their own kind. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as @@ -7862,12 +8103,19 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `composition`, `hyphenation` or -`justification`) and `preview`, and these fields: +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation`, +`justification` or `eastAsian`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and `space_after`, typed in the General unit of Preferences or with a unit of their own. +- Tabs: `tabs`, the style's stops, each `{position, align, leader, alignOn}`: `position` typed like the distances + above, `align` `left`, `center`, `right` or `decimal`, `leader` up to 8 characters, and `alignOn` one character + (used by decimal stops). The section edits the selected stop with Alignment, X: (Y: for vertical type), Leader: + and Align On:. Add puts a left stop 36 pt past the furthest stop (at 36 pt when there is none, and at most at + 100000 pt); it is disabled at 256 stops or when a stop is already at that position. Delete Tab removes the selected + stop, and Clear All Tabs leaves the style with no stops, which it then sets. OK sorts the stops by + position; of two stops at one position it keeps the one edited last. - Composition: `composer` (`singleLine` or `everyLine`) and the `roman_hanging` checkbox. - Hyphenation: the `hyphenate` checkbox, `words_longer_than`, `after_first`, `before_last`, `hyphen_limit`, `hyphenation_zone` and the `hyphenate_capitalized` checkbox. The options are inactive while Hyphenation is off, and @@ -7875,20 +8123,25 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `co - Justification: `word_min`, `word_desired`, `word_max`, `letter_min`, `letter_desired`, `letter_max`, `glyph_min`, `glyph_desired` and `glyph_max` (percent), `auto_leading` (percent) and `single_word` (`fullJustify`, `alignLeft`, `alignCenter` or `alignRight`). +- East Asian Composition Settings: the `korean_line_break_anywhere` checkbox (Korean Composition Settings › Korean + Line Break Anywhere). A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes -the dialog does not show, such as tabs and character attributes, stay as they are. +the dialog does not show, such as character attributes, stay as they are. `paraStyle.new`, `paraStyle.setAttrs` and `paraStyle.edit` reject a changed indent, space before or space after outside -1296 to 1296 pt, Words Longer Than outside 1 to 25, After First or Before Last outside 1 to 15, Hyphen Limit outside 0 to 25, Hyphenation Zone outside 0 to 1296 pt, Word Spacing outside 0 to 1000%, Letter Spacing outside -100 to 500%, Glyph Scaling outside 50 to 200%, Minimum, Desired and Maximum values out of that order, Auto Leading outside 0 to -500%, and a tab position outside 0 to 100000 pt. A value the style already has is kept. An invalid value leaves the +500%, and changed tabs with more than 256 stops, or with a changed stop whose position is outside 0 to 100000 pt or +whose leader is longer than 8 characters. They sort changed tabs by position and keep the first of stops at one +position. A value the style already has is kept. An invalid value leaves the style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the -five distances, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and -`hyphenation`, and -Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section +five distances, Tabs removes `tabs`, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and +`hyphenation`, +Justification removes `justification`, and East Asian Composition Settings removes `korean_line_break_anywhere`. +`ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every @@ -7975,13 +8228,15 @@ drawings leave a deferred stage as it is until its input or parameters change, w the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list when none is deferred). A stage whose object is deleted, or whose plug-in effect is -removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh +removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?|id?}` (Effect › Refresh Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them) again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn, and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that timed out, failed or have no plug-in. `id` names one object in place of -`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand +`ids`. An entry can name an object in the document's layers or in the art of a symbol, pattern, brush or opacity mask; +an object without a deferred stage adds nothing to the counts, and an entry that is not an object of the document is +an error. Each document has its own budget and its own deferred stages. Exports, Expand Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`): @@ -8009,7 +8264,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 +8811,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 +8820,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 +8847,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}}} @@ -9653,7 +9929,9 @@ VectorCraft addition and changes no parity score. - **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on one straight line along the object's own axis, with the baseline starting where the first character sat on the path (type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip, - brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point + brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point + type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor. + Without `keepOriginal` the type object itself becomes the point type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as `type.convertToPointType` does for area type. - It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left diff --git a/docs/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..51b0eb31bb 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). > @@ -149,9 +149,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Bristle Brushes, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | +| 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 b2e0e6f4be..fae1fbeae1 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 (Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -216,11 +218,11 @@ 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) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Bristle Brushes, Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -228,7 +230,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | @@ -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) | @@ -487,13 +489,13 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Bristle Brushes, Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | @@ -539,7 +541,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -577,6 +579,7 @@ since 2026-10-01. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. +- **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -617,6 +620,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). @@ -637,7 +642,8 @@ since 2026-10-01. - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). - **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`), as the panel menu's item does. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. - **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. -- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. Tabs and the character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. The character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, Tabs:** the Tabs section of Paragraph Style Options and New Paragraph Style lists a style's tab stops and edits the selected stop's Alignment (Left, Center, Right or Decimal), position (X:, or Y: for vertical type), Leader and Align On character; Add, Delete Tab and Clear All Tabs add a stop 36 pt past the furthest, remove the selected one, or leave the style with no stops. OK sorts the stops by position, keeps the stop edited last of two at one position, and saves one undo step; Preview and Cancel restore the paragraphs, Reset Panel removes the style's tabs, and a position outside 0 to 100000 pt, a leader longer than 8 characters or more than 256 stops keeps the dialog open. Tests `dialogs::para_style_options::tests::tabs_section_edits_the_style_stops` and `tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step`. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. @@ -659,7 +665,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Ruler double-click (#5239):** a double-click (both clicks on that ruler) on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. egui frame tests cover both rulers, a lone click, Undo, a canvas click before a ruler click, and rotated views. -- **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). +- **Rulers (#1212):** drag the intersection to preview and set a document zero point, snapped as a drawn point is (with Smart Guides onto the art's and the artboards' anchors, centres, edges and paths or into line with them, with Snap to Point along the ruler guides, else to pixels or the grid), or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). - **Artboard guide labels (#6414, in part):** Artboard Options' Guide Label gives an artboard a color label (`artboard.setProps`/`artboard.new` `guideLabel`: a layer color's name, `#rrggbb` or null), and the canvas draws that artboard's own guides in it; canvas guides, guides of an unlabeled artboard and guides whose artboard is gone keep the guide color preference, and selected guides keep the selection color. The [help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) describes guides restricted to an artboard, not a label, so the label, its palette and the color precedence are VectorCraft's own. It is screen-only (exports and print leave it out), written to the native format only when set, kept by duplicates and copies, and changeable on a locked artboard; `artboard.setProps` adds no undo step when nothing changes. This VectorCraft addition does not raise a score. Regression tests: `tests/panelcmds.rs` (`artboard_guide_labels_are_set_and_cleared_in_one_undo_step`, `artboard_guide_labels_round_trip_through_the_native_format`), `doc` `tests.rs` (`guide_label_text_names_a_layer_color_or_rgb`, `artboard_guide_label_defaults_and_skips_in_json`), `canvas::tests` (`artboard_guide_label_colors_only_its_own_guides`), `dialogs/tests.rs` (`artboard_options_shows_sets_and_clears_the_guide_label`). @@ -743,32 +749,37 @@ 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`. - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. + - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. `crates/engine/src/tests/script_engines.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. + - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: 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). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit; a `\u{…}` escape takes any number of hexadecimal digits up to U+10FFFF, and one that is empty, unclosed, out of range or a surrogate is not part of a name. Each `\u{…}` escape read in a name is reported as syntax newer than ES3, which has only the four-digit form (https://ecma-international.org/wp-content/uploads/ECMA-262_3rd_edition_december_1999.pdf, section 7.6). A private name may start with an escape (`this.#\u{61}pp` is the private name `#app`), and a private name at the start of a line inside braces is not read as a directive unless its name is a directive's. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`, `braced_escapes_in_names_are_read_by_their_value`, `braced_name_escape_reports_later_syntax_and_member`, `private_names_with_escaped_starts_are_single_tokens`, `private_name_at_line_start_is_not_a_directive`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 17.0, so U+088F starts an identifier and U+1ACF may follow its first character; characters assigned after Unicode 17.0 are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_17_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_characters_unicode_17_assigned`, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `<!DOCTYPE>` in an XML string gives a `SyntaxError`. 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`. + - 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. `xml.xpath(expression[, variables])` evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with all 13 axes and the 27 core functions, and returns the selected nodes as an XMLList in document order without repeats. Each property of `variables` binds the variable of its name, an XMLList runs the expression on each of its elements in turn, and a prefix in a name test resolves through the context element's in-scope namespaces. An expression that breaks the grammar gives a `SyntaxError`; an unknown function, a wrong argument count, an unbound prefix or variable, and a result that is not a node-set or that holds the root or a namespace node give a `TypeError`. `id()` selects nothing, and evaluation uses no native stack, takes fuel and counts against the heap limit. `<!DOCTYPE>` in an XML string gives a `SyntaxError`. 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. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -817,6 +828,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). + - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. @@ -840,7 +852,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -890,7 +903,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning of the character before it. + - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning there: of the character before it, or at the start of a line the space before the character after it (#5111). - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. - Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a layer through the New Layer dialog box. OK adds the layer above the current layer, and Cancel adds none. Cmd/Ctrl+L still adds a layer without the dialog. - F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels. @@ -1143,6 +1156,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). +- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn, and on a path writes the space as the first character's `dx`; vertical type moves the character down its column. A space on the first letter of a ligature keeps the ligature whole. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. @@ -1180,15 +1194,16 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). - Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). - 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`). + 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, 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. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. @@ -1276,6 +1291,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 +1299,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, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. @@ -1344,6 +1360,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. + A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). + Art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the Brushes panel becomes the brush's art, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.replaceArt`). When strokes use the brush, a dialog asks whether to change those strokes (Apply to Strokes) or leave them as they are (Leave Strokes), or to cancel, the choice the [help reference](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=263) (archived copy, p. 263) describes for a modified brush in use. Leave Strokes keeps the brush and its strokes as they were and adds a copy of the brush with the new art, named "`<name>` copy", which becomes the current brush. That copy is VectorCraft's own rule. A pattern brush takes back the tile groups `brush.placeArt` places, frames included, and takes other art as its side tile, which is also VectorCraft's own rule. Evidence: `tests::brushsym::replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush`, `tests::brushsym::placed_pattern_tiles_go_back_into_the_brush_tile_by_tile`, `tests::brushsym::other_art_replaces_the_side_tile_of_a_pattern_brush` (vectorcraft-engine), `dialogs::brush_replace_art::tests::leave_strokes_adds_a_copy_and_cancel_changes_nothing`, `panels::brushes::tests::art_alt_dropped_on_a_brush_with_art_replaces_its_art`, `canvas::tests::art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original` (vectorcraft-ui-egui). In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). @@ -1361,7 +1379,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; nine generated style libraries (including Game HUD, Status Effects and Textures) save and load as `.vcstyles`. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; ten generated style libraries (including Game HUD, Status Effects, Painted and Sketched, whose seven styles each repaint a solid fill with one watercolor, dry brush, pastel, charcoal or ink effect, and Textures) save and load as `.vcstyles`. The built-in Textures graphic style library (`graphicStyle.addFromLibrary {library: "textures"}`) has eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The help page names new graphic styles for textures ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html)) without naming them, so the styles, their names and their settings are VectorCraft's own (`tests::stylelib::textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open`). In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. Applying a style to several objects, and Redefine Graphic Style, give the objects one shared copy of the style's appearance instead of a copy each; an object that is edited later gets its own copy, and an object whose placed gradients are fitted to its own bounds gets its own. The undo history's memory estimate counts a shared appearance once. Saved files are unchanged (`crates/engine/src/tests/styles.rs`). @@ -1502,13 +1520,16 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 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. @@ -1517,7 +1538,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`). - Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). + - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). @@ -1686,6 +1707,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,13 +1717,17 @@ 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. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.rs`. Scores and hours unchanged. +- **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings a failed removal can't move back:** when `plugin.remove` can't remove the grant and can't rename the plug-in's `.removed` folder back, its error names that folder, and the next load, install or removal of the plug-in renames it back instead of deleting it. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. @@ -1977,7 +2003,7 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Color Effects add-on: Find Nearest Swatch (2026-11-01) @@ -2032,10 +2058,25 @@ 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-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | +| 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | +| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | +| 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | @@ -2427,4 +2468,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/three-d.md b/docs/three-d.md index 1026a9e1be..a79e6a4900 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -141,7 +141,7 @@ them all. The members stay editable: moving or editing one reshapes the solids. ## Clipping groups, blends and symbol instances -Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate and Revolve) also applies to a clipping +Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate, Rotate (Classic), Revolve and Revolve (Classic)) also applies to a clipping group, a blend or a symbol instance, alone or inside a group or layer. The help page says that effects apply to objects, groups or layers ([Apply and modify effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html)), that a clipping mask hides everything outside its shape ([Clipping masks](https://helpx.adobe.com/illustrator/using/clipping-masks.html)), @@ -279,7 +279,7 @@ Our reading: command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded file draws the source path. - Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a - path below the faces. Extrude & Bevel (Classic) has no shadow. + path below the faces. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. ## Parameters (`effect.apply`, `effect.setParams`) @@ -336,8 +336,11 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a circle builds. -- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), - Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. +- **3D (Classic)**: Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) are built, with their surface + types and blend steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws + every face back to front, so it has no Draw Hidden Faces setting. Rotate (Classic) turns the plane Rotate turns, seen + from either side, with the No Shading and Diffuse surfaces only. The track cube, map art, Turntable and 3D export + are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). diff --git a/docs/type-parity.md b/docs/type-parity.md index 7d7af40c51..30547d5a17 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | | 2026-11-11 | minor | Type in envelopes: its strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them; with Distort Appearance they bent with the art as their filled outlines. The same holds in an envelope inside an envelope. Geometry effects on type's fills and strokes, and an opacity mask on type, still bend; Expand gives the bent outlines with the strokes on them. The visual bounds of envelopes, blends and repeats count type's character strokes as type's own visual bounds do. This changes how VectorCraft's own envelopes of stroked type draw (#6644). Score unchanged (estimated) | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58e5276573..dbe69b7603 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-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 (Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -240,7 +240,7 @@ tools, never checked side by side) isn't captured row by row. | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | | A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | -| Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | +| Ruler origin: corner drag with preview, snapped as a drawn point is (Smart Guides, Snap to Point and the ruler guides, pixels, the grid), double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | | Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on, by the nearest point of the path when pressed on its stroke or beside its edge, so the path lands exactly on the guide | done | #1534 (#1543, #1583); #3701 | | A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim | @@ -373,6 +373,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`; `panels::learn::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | @@ -436,7 +437,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 +510,19 @@ 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 | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. | +| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | +| 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 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. |